Skip to content

Commit d369f5c

Browse files
committed
perf(EditorToolbar): batch toolbar updates and memoize item state
1 parent 52a04b6 commit d369f5c

2 files changed

Lines changed: 219 additions & 29 deletions

File tree

src/runtime/components/EditorToolbar.vue

Lines changed: 135 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export type EditorToolbarSlots<
9696
</script>
9797

9898
<script setup lang="ts" generic="T extends ArrayOrNested<EditorToolbarItem>">
99-
import { computed, inject } from 'vue'
99+
import { computed, inject, onBeforeUnmount, shallowRef, watch } from 'vue'
100100
import { Primitive, Separator, useForwardProps } from 'reka-ui'
101101
import { defu } from 'defu'
102102
import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
@@ -154,59 +154,118 @@ const groups = computed(() =>
154154
: []
155155
)
156156
157-
function isActive(item: EditorToolbarItem): boolean {
158-
if (!props.editor?.isEditable) {
159-
return false
157+
type ToolbarItemState = {
158+
active: boolean
159+
disabled: boolean
160+
}
161+
162+
const stateVersion = shallowRef(0)
163+
const itemStateCache = new WeakMap<object, { version: number, state: ToolbarItemState }>()
164+
const dropdownItemsCache = new WeakMap<object, { version: number, items: DropdownMenuItem[][] }>()
165+
let refreshFrameId: number | null = null
166+
167+
function scheduleStateRefresh() {
168+
if (refreshFrameId !== null) {
169+
return
160170
}
161171
162-
// Check for dropdown (has items property)
163-
if (('items' in item) && item.items?.length) {
164-
return item.items?.some((item): boolean => isActive(item as EditorToolbarItem)) || false
172+
if (typeof requestAnimationFrame === 'function') {
173+
refreshFrameId = requestAnimationFrame(() => {
174+
refreshFrameId = null
175+
stateVersion.value++
176+
})
177+
return
165178
}
166179
167-
// Check for plain button (no kind property)
168-
if (!('kind' in item)) {
169-
return item.active ?? false
180+
stateVersion.value++
181+
}
182+
183+
function cancelStateRefresh() {
184+
if (refreshFrameId !== null && typeof cancelAnimationFrame === 'function') {
185+
cancelAnimationFrame(refreshFrameId)
170186
}
171187
172-
// Check for editor item (has kind property)
173-
const handler = handlers?.value?.[item.kind]
174-
return handler?.isActive(props.editor, item as any) || false
188+
refreshFrameId = null
175189
}
176190
177-
function isDisabled(item: EditorToolbarItem): boolean {
191+
function getItemState(item: EditorToolbarItem): ToolbarItemState {
192+
const currentVersion = stateVersion.value
193+
const key = item as object
194+
const cached = itemStateCache.get(key)
195+
196+
if (cached && cached.version === currentVersion) {
197+
return cached.state
198+
}
199+
200+
const state = resolveItemState(item)
201+
itemStateCache.set(key, {
202+
version: currentVersion,
203+
state
204+
})
205+
206+
return state
207+
}
208+
209+
function resolveItemState(item: EditorToolbarItem): ToolbarItemState {
178210
if (!props.editor?.isEditable) {
179-
return true
211+
return {
212+
active: false,
213+
disabled: true
214+
}
180215
}
181216
182217
if ('items' in item && item.items?.length) {
183218
const items = isArrayOfArray(item.items) ? item.items.flat() : item.items
184-
// Filter out structural elements (separators, labels)
185-
const actionableItems = items.filter((item: any) => item.type !== 'separator' && item.type !== 'label')
219+
let hasActionableItems = false
220+
let active = false
221+
let allActionableDisabled = true
222+
223+
for (const child of items as EditorToolbarDropdownChildItem[]) {
224+
const state = getItemState(child as EditorToolbarItem)
225+
const type = (child as DropdownMenuItem).type
226+
const isStructural = type === 'separator' || type === 'label'
227+
228+
active = active || state.active
186229
187-
if (actionableItems.length === 0) {
188-
return true
230+
if (!isStructural) {
231+
hasActionableItems = true
232+
allActionableDisabled = allActionableDisabled && state.disabled
233+
}
189234
}
190235
191-
return actionableItems.every((item: any) => isDisabled(item))
236+
return {
237+
active,
238+
disabled: !hasActionableItems || allActionableDisabled
239+
}
192240
}
193241
194242
if (!('kind' in item)) {
195-
return item.disabled ?? false
243+
return {
244+
active: item.active ?? false,
245+
disabled: item.disabled ?? false
246+
}
196247
}
197248
198249
const handler = handlers?.value?.[item.kind]
199250
if (!handler) {
200-
return false
251+
return {
252+
active: false,
253+
disabled: false
254+
}
201255
}
202256
203-
// Check item-specific disabled state
204-
if (handler.isDisabled?.(props.editor, item)) {
205-
return true
257+
return {
258+
active: handler.isActive(props.editor, item as any) || false,
259+
disabled: !!handler.isDisabled?.(props.editor, item) || !handler.canExecute(props.editor, item)
206260
}
261+
}
207262
208-
// Check if item can be executed
209-
return !handler.canExecute(props.editor, item)
263+
function isActive(item: EditorToolbarItem): boolean {
264+
return getItemState(item).active
265+
}
266+
267+
function isDisabled(item: EditorToolbarItem): boolean {
268+
return getItemState(item).disabled
210269
}
211270
212271
function onClick(e: MouseEvent, item: EditorToolbarItem) {
@@ -240,7 +299,7 @@ function getActiveChildItem(item: EditorToolbarDropdownItem): EditorToolbarItem
240299
if (!('kind' in childItem)) {
241300
return false
242301
}
243-
return isActive(childItem as EditorToolbarItem)
302+
return getItemState(childItem as EditorToolbarItem).active
244303
}) as EditorToolbarItem | undefined
245304
}
246305
@@ -298,14 +357,61 @@ function mapDropdownItem(item: EditorToolbarDropdownChildItem): DropdownMenuItem
298357
}
299358
300359
function getDropdownItems(item: EditorToolbarDropdownItem) {
360+
const currentVersion = stateVersion.value
361+
const key = item as object
362+
const cached = dropdownItemsCache.get(key)
363+
364+
if (cached && cached.version === currentVersion) {
365+
return cached.items
366+
}
367+
301368
if (!item.items) {
302369
return []
303370
}
304371
305-
return isArrayOfArray(item.items)
372+
const mappedItems = isArrayOfArray(item.items)
306373
? item.items.map((group: any) => group.map(mapDropdownItem))
307374
: [item.items.map(mapDropdownItem)]
375+
376+
dropdownItemsCache.set(key, {
377+
version: currentVersion,
378+
items: mappedItems
379+
})
380+
381+
return mappedItems
308382
}
383+
384+
watch(() => handlers.value, () => {
385+
scheduleStateRefresh()
386+
}, { deep: true })
387+
388+
watch(() => props.editor, (editor, _, onCleanup) => {
389+
scheduleStateRefresh()
390+
391+
if (typeof (editor as any)?.on !== 'function' || typeof (editor as any)?.off !== 'function') {
392+
return
393+
}
394+
395+
const onStateChange = () => {
396+
scheduleStateRefresh()
397+
}
398+
399+
editor.on('selectionUpdate', onStateChange)
400+
editor.on('focus', onStateChange)
401+
editor.on('blur', onStateChange)
402+
editor.on('transaction', onStateChange)
403+
404+
onCleanup(() => {
405+
editor.off('selectionUpdate', onStateChange)
406+
editor.off('focus', onStateChange)
407+
editor.off('blur', onStateChange)
408+
editor.off('transaction', onStateChange)
409+
})
410+
}, { immediate: true })
411+
412+
onBeforeUnmount(() => {
413+
cancelStateRefresh()
414+
})
309415
</script>
310416

311417
<template>

test/components/EditorToolbar.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi } from 'vitest'
22
import { axe } from 'vitest-axe'
33
import { mountSuspended } from '@nuxt/test-utils/runtime'
4+
import { computed, nextTick } from 'vue'
45
import { renderEach } from '../component-render'
56
import type { Editor } from '@tiptap/vue-3'
67
import EditorToolbar from '../../src/runtime/components/EditorToolbar.vue'
@@ -61,6 +62,30 @@ describe('EditorToolbar', () => {
6162
}]]
6263
const props = { editor: { registerPlugin: vi.fn() } as unknown as Editor, items }
6364

65+
function createMockEditor() {
66+
const listeners = new Map<string, Set<() => void>>()
67+
68+
return {
69+
isEditable: true,
70+
registerPlugin: vi.fn(),
71+
on: vi.fn((event: string, callback: () => void) => {
72+
if (!listeners.has(event)) {
73+
listeners.set(event, new Set())
74+
}
75+
76+
listeners.get(event)?.add(callback)
77+
}),
78+
off: vi.fn((event: string, callback: () => void) => {
79+
listeners.get(event)?.delete(callback)
80+
}),
81+
emit(event: string) {
82+
for (const callback of listeners.get(event) || []) {
83+
callback()
84+
}
85+
}
86+
} as unknown as Editor & { emit: (event: string) => void }
87+
}
88+
6489
renderEach(EditorToolbar, [
6590
// Props
6691
['with as', { props: { ...props, as: 'section' } }],
@@ -79,4 +104,63 @@ describe('EditorToolbar', () => {
79104

80105
expect(await axe(wrapper.element)).toHaveNoViolations()
81106
})
107+
108+
it('avoids recomputing handler state on unrelated rerenders', async () => {
109+
const editor = createMockEditor()
110+
const isActive = vi.fn(() => false)
111+
const canExecute = vi.fn(() => true)
112+
const isDisabled = vi.fn(() => false)
113+
114+
const wrapper = await mountSuspended(EditorToolbar, {
115+
props: {
116+
editor,
117+
items: [[{
118+
'kind': 'customAction',
119+
'icon': 'i-lucide-wand-sparkles',
120+
'aria-label': 'Custom action'
121+
} as any]]
122+
},
123+
global: {
124+
provide: {
125+
editorHandlers: computed(() => ({
126+
customAction: {
127+
isActive,
128+
canExecute,
129+
isDisabled,
130+
execute: () => ({
131+
run: () => true
132+
})
133+
}
134+
}))
135+
}
136+
}
137+
})
138+
139+
const initialIsActiveCalls = isActive.mock.calls.length
140+
const initialCanExecuteCalls = canExecute.mock.calls.length
141+
const initialIsDisabledCalls = isDisabled.mock.calls.length
142+
143+
await wrapper.setProps({ class: 'overflow-x-auto' })
144+
await nextTick()
145+
146+
expect(isActive).toHaveBeenCalledTimes(initialIsActiveCalls)
147+
expect(canExecute).toHaveBeenCalledTimes(initialCanExecuteCalls)
148+
expect(isDisabled).toHaveBeenCalledTimes(initialIsDisabledCalls)
149+
150+
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+
})
158+
await nextTick()
159+
160+
expect(isActive).toHaveBeenCalledTimes(initialIsActiveCalls + 1)
161+
expect(canExecute).toHaveBeenCalledTimes(initialCanExecuteCalls + 1)
162+
expect(isDisabled).toHaveBeenCalledTimes(initialIsDisabledCalls + 1)
163+
164+
wrapper.unmount()
165+
})
82166
})

0 commit comments

Comments
 (0)