Skip to content

Commit 07fbade

Browse files
committed
perf(editor-toolbar): batch toolbar state updates with rAF and avoid redundant editor serialization
1 parent 337d3d1 commit 07fbade

4 files changed

Lines changed: 121 additions & 24 deletions

File tree

src/runtime/components/Editor.vue

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export interface EditorSlots<H extends EditorCustomHandlers = EditorCustomHandle
8585
</script>
8686

8787
<script setup lang="ts" generic="T extends Content, H extends EditorCustomHandlers">
88-
import { computed, provide, useAttrs, watch } from 'vue'
88+
import { computed, provide, shallowRef, useAttrs, watch } from 'vue'
8989
import { defu } from 'defu'
9090
import { Primitive, useForwardProps } from 'reka-ui'
9191
import { mergeAttributes } from '@tiptap/core'
@@ -133,6 +133,7 @@ const editorProps = computed(() => defu(props.editorProps, {
133133
}
134134
} as EditorOptions['editorProps']))
135135
const contentType = computed(() => props.contentType || (typeof props.modelValue === 'string' ? 'html' : 'json'))
136+
const lastSyncedContent = shallowRef(serializeContent(props.modelValue, contentType.value))
136137
const starterKit = computed(() => defu(props.starterKit, {
137138
code: false,
138139
horizontalRule: false,
@@ -222,43 +223,48 @@ const editor = useEditor({
222223
value = editor.getText()
223224
}
224225
226+
lastSyncedContent.value = serializeContent(value as T, contentType.value)
225227
emits('update:modelValue', value as T)
226228
}
227229
})
228230
229-
watch(() => props.modelValue, (newVal) => {
231+
watch([() => props.modelValue, contentType], ([newVal, type]) => {
230232
if (!editor.value || newVal == null) {
231233
return
232234
}
233235
234-
const currentContent = contentType.value === 'html'
235-
? editor.value.getHTML()
236-
: contentType.value === 'json'
237-
? JSON.stringify(editor.value.getJSON())
238-
: contentType.value === 'markdown'
239-
? editor.value.getMarkdown()
240-
: editor.value.getText()
236+
const newContent = serializeContent(newVal, type)
241237
242-
const newContent = contentType.value === 'json' && typeof newVal === 'object'
243-
? JSON.stringify(newVal)
244-
: String(newVal)
245-
246-
if (currentContent !== newContent) {
238+
if (newContent !== lastSyncedContent.value) {
247239
// Store current cursor position
248240
const currentSelection = editor.value.state.selection
249241
const currentPos = currentSelection.from
250242
251243
// Set the new content
252-
editor.value.commands.setContent(newVal, { contentType: contentType.value })
244+
editor.value.commands.setContent(newVal, { contentType: type })
253245
254246
// Restore cursor position if the position is still valid in the new content
255247
const newDoc = editor.value.state.doc
256248
if (currentPos <= newDoc.content.size) {
257249
editor.value.commands.setTextSelection(currentPos)
258250
}
251+
252+
lastSyncedContent.value = newContent
259253
}
260254
})
261255
256+
function serializeContent(value: Content | undefined, type: EditorContentType) {
257+
if (value == null) {
258+
return ''
259+
}
260+
261+
if (type === 'json' && typeof value === 'object') {
262+
return JSON.stringify(value)
263+
}
264+
265+
return String(value)
266+
}
267+
262268
const handlers = computed(() => ({
263269
...createHandlers(),
264270
...props.handlers

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/Editor.spec.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, vi } from 'vitest'
22
import { axe } from 'vitest-axe'
33
import { mountSuspended } from '@nuxt/test-utils/runtime'
4+
import { defineComponent, nextTick, ref } from 'vue'
45
import { renderEach } from '../component-render'
56
import Editor from '../../src/runtime/components/Editor.vue'
67

@@ -26,4 +27,34 @@ describe('Editor', () => {
2627

2728
wrapper.unmount()
2829
})
30+
31+
it('avoids serializing markdown twice for internal v-model updates', async () => {
32+
const wrapper = await mountSuspended(defineComponent({
33+
components: { Editor },
34+
setup() {
35+
const modelValue = ref('# Nuxt UI\n\nEditor content')
36+
37+
return {
38+
modelValue
39+
}
40+
},
41+
template: '<Editor v-model="modelValue" content-type="markdown" />'
42+
}))
43+
44+
const editorWrapper = (wrapper as any).getComponent(Editor)
45+
const exposedEditor = (editorWrapper.vm as { editor?: any }).editor
46+
const editor = exposedEditor?.commands ? exposedEditor : exposedEditor?.value
47+
48+
expect(editor).toBeTruthy()
49+
50+
const getMarkdown = vi.spyOn(editor, 'getMarkdown')
51+
getMarkdown.mockClear()
52+
53+
editor.commands.setContent('# Nuxt UI', { contentType: 'markdown' })
54+
55+
await nextTick()
56+
await nextTick()
57+
58+
expect(getMarkdown).toHaveBeenCalledTimes(1)
59+
})
2960
})

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)