Skip to content

Commit 6a97ef4

Browse files
committed
perf(editor-toolbar): batch toolbar updates with rAF and reduce redundant dropdown updates
1 parent 337d3d1 commit 6a97ef4

2 files changed

Lines changed: 68 additions & 8 deletions

File tree

src/runtime/components/EditorToolbar.vue

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export type EditorToolbarSlots<
9595
</script>
9696

9797
<script setup lang="ts" generic="T extends ArrayOrNested<EditorToolbarItem>">
98-
import { computed, inject, shallowRef, watch } from 'vue'
98+
import { computed, inject, onBeforeUnmount, shallowRef, watch } from 'vue'
9999
import type { ShallowRef } from 'vue'
100100
import { Primitive, Separator, useForwardProps } from 'reka-ui'
101101
import { defu } from 'defu'
@@ -391,6 +391,8 @@ function buildRenderGroups(): ToolbarRenderEntry[][] {
391391
}
392392
393393
const renderGroups = shallowRef<ToolbarRenderEntry[][]>(buildRenderGroups())
394+
let pendingFrameId: number | null = null
395+
let pendingForceRefresh = false
394396
395397
function refreshState(force = false) {
396398
for (const group of renderGroups.value) {
@@ -404,20 +406,55 @@ function refreshState(force = false) {
404406
405407
if ('items' in entry.item && entry.item.items?.length) {
406408
const previousDropdownState = entry.dropdownState.value
409+
const dropdownChanged = !sameDropdownButtonProps(previousDropdownState, resolved.dropdown)
407410
408-
// Always update dropdown items since child active/disabled states may change
409-
entry.dropdownState.value = resolved.dropdown
410-
entry.dropdownItems.value = resolved.dropdown?.items || []
411+
if (force || stateChanged || dropdownChanged) {
412+
entry.dropdownState.value = resolved.dropdown
413+
entry.dropdownItems.value = resolved.dropdown?.items || []
414+
}
411415
412416
// Only rebuild button props when activeChild icon/label changes
413-
if (stateChanged || force || !sameDropdownButtonProps(previousDropdownState, resolved.dropdown)) {
417+
if (stateChanged || force || dropdownChanged) {
414418
entry.buttonProps.value = buildButtonProps(entry.item, resolved.dropdown)
415419
}
416420
}
417421
}
418422
}
419423
}
420424
425+
function flushRefresh() {
426+
const force = pendingForceRefresh
427+
pendingForceRefresh = false
428+
pendingFrameId = null
429+
refreshState(force)
430+
}
431+
432+
function scheduleRefresh(force = false) {
433+
pendingForceRefresh ||= force
434+
435+
if (pendingFrameId !== null) {
436+
return
437+
}
438+
439+
if (typeof requestAnimationFrame === 'function') {
440+
pendingFrameId = requestAnimationFrame(() => {
441+
flushRefresh()
442+
})
443+
return
444+
}
445+
446+
flushRefresh()
447+
}
448+
449+
function cancelScheduledRefresh() {
450+
if (pendingFrameId !== null && typeof cancelAnimationFrame === 'function') {
451+
cancelAnimationFrame(pendingFrameId)
452+
}
453+
454+
pendingFrameId = null
455+
pendingForceRefresh = false
456+
}
457+
421458
watch(() => props.items, () => {
422459
renderGroups.value = buildRenderGroups()
423460
}, { deep: true })
@@ -427,27 +464,41 @@ watch(() => [props.color, props.variant, props.activeColor, props.activeVariant,
427464
})
428465
429466
watch(() => handlers.value, () => {
430-
refreshState(true)
467+
scheduleRefresh(true)
431468
}, { deep: true })
432469
433470
watch(() => props.editor, (editor, _, onCleanup) => {
434-
refreshState(true)
471+
scheduleRefresh(true)
435472
436473
if (typeof (editor as any)?.on !== 'function' || typeof (editor as any)?.off !== 'function') {
437474
return
438475
}
439476
477+
const onSelectionUpdate = () => {
478+
scheduleRefresh()
479+
}
480+
440481
const onTransaction = () => {
441-
refreshState()
482+
scheduleRefresh()
442483
}
443484
485+
editor.on('selectionUpdate', onSelectionUpdate)
486+
editor.on('focus', onSelectionUpdate)
487+
editor.on('blur', onSelectionUpdate)
444488
editor.on('transaction', onTransaction)
445489
446490
onCleanup(() => {
491+
editor.off('selectionUpdate', onSelectionUpdate)
492+
editor.off('focus', onSelectionUpdate)
493+
editor.off('blur', onSelectionUpdate)
447494
editor.off('transaction', onTransaction)
448495
})
449496
}, { immediate: true })
450497
498+
onBeforeUnmount(() => {
499+
cancelScheduledRefresh()
500+
})
501+
451502
function getRenderEntry(key: string) {
452503
return renderEntryMap.value.get(key)
453504
}

test/components/EditorToolbar.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,19 @@ describe('EditorToolbar', () => {
148148
expect(isDisabled).toHaveBeenCalledTimes(initialIsDisabledCalls)
149149

150150
editor.emit('transaction')
151+
await new Promise<void>((resolve) => {
152+
if (typeof requestAnimationFrame === 'function') {
153+
requestAnimationFrame(() => resolve())
154+
return
155+
}
156+
setTimeout(() => resolve(), 0)
157+
})
151158
await nextTick()
152159

153160
expect(isActive).toHaveBeenCalledTimes(initialIsActiveCalls + 1)
154161
expect(canExecute).toHaveBeenCalledTimes(initialCanExecuteCalls + 1)
155162
expect(isDisabled).toHaveBeenCalledTimes(initialIsDisabledCalls + 1)
163+
164+
wrapper.unmount()
156165
})
157166
})

0 commit comments

Comments
 (0)