diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d03fd2fb..703947535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Project history diff views: add revert changes button to markdown editor * Send update_text events with text diff when updating text fields via API instead of overwriting the whole text * Fix MDE preview layout break on zoom out +* Throttle MDE update events to prevent browser from hanging * Fix elastic APM tracing middleware always enabled diff --git a/frontend/src/components/EditToolbar.vue b/frontend/src/components/EditToolbar.vue index 5a7dd7444..45fea02ea 100644 --- a/frontend/src/components/EditToolbar.vue +++ b/frontend/src/components/EditToolbar.vue @@ -164,7 +164,7 @@ const savingInProgress = ref(false); const deletingInProgress = ref(false); const actionInProgress = computed(() => savingInProgress.value || deletingInProgress.value); -const previousData = ref(null); +const previousData = shallowRef(null); const isDestroying = ref(false); const lockingInProgress = ref(false); const lockInfo = ref(null); diff --git a/frontend/src/composables/markdown.ts b/frontend/src/composables/markdown.ts index 2b8ff7e74..75b6be865 100644 --- a/frontend/src/composables/markdown.ts +++ b/frontend/src/composables/markdown.ts @@ -1,5 +1,6 @@ import { v4 as uuid4 } from 'uuid'; import type { PropType } from "vue"; +import throttle from "lodash/throttle"; import { createEditorExtensionToggler, EditorState, EditorView, ViewUpdate, @@ -181,6 +182,57 @@ export function useMarkdownEditorBase(options: { const editorActions = ref<{[key: string]: (enabled: boolean) => void}>({}); const eventBusBeforeApplyRemoteTextChanges = useEventBus('collab:beforeApplyRemoteTextChanges'); + const pendingViewUpdateEvents = shallowRef([]); + const sendUpdateEventsThrottled = throttle(() => { + // Throttle and batch update events to prevent the UI from hanging. + // Updating reactive data in stores is costly and slow. + + const pendingUpdates = [...pendingViewUpdateEvents.value]; + pendingViewUpdateEvents.value = []; + if (pendingUpdates.length === 0) { + return; + } + + let newModelValue = valueNotNull.value; + let newSelection = null; + const newTextUpdates = []; + for (const viewUpdate of pendingUpdates) { + for (const tr of viewUpdate.transactions) { + if (tr.docChanged && !tr.annotation(Transaction.remote)) { + newTextUpdates.push({ changes: tr.changes, selection: tr.selection }); + } + } + if (viewUpdate.docChanged) { + newModelValue = viewUpdate.state.doc.toString(); + } + + if (viewUpdate.selectionSet || viewUpdate.focusChanged) { + newSelection = { changed: true, selection: viewUpdate.state.selection }; + } + } + + if (options.props.value.collab && newTextUpdates.length > 0) { + options.emit('collab', { + type: CollabEventType.UPDATE_TEXT, + path: options.props.value.collab.path, + updates: newTextUpdates, + }); + } + if (newModelValue !== valueNotNull.value) { + options.emit('update:modelValue', newModelValue); + } + if (options.props.value.collab && newSelection?.changed) { + // Collab awareness updates + const hasFocus = options.editorView.value?.hasFocus || false; + options.emit('collab', { + type: CollabEventType.AWARENESS, + path: options.props.value.collab.path, + focus: hasFocus, + selection: newSelection.selection, + }); + } + }, 500, { leading: true }) + function createEditorStateConfig() { return { doc: valueNotNull.value, @@ -194,36 +246,12 @@ export function useMarkdownEditorBase(options: { focus: (event: FocusEvent) => options.emit('focus', event), }), EditorView.updateListener.of((viewUpdate: ViewUpdate) => { - editorState.value = viewUpdate.state; // https://discuss.codemirror.net/t/codemirror-6-proper-way-to-listen-for-changes/2395/11 - if (viewUpdate.docChanged && viewUpdate.state.doc.toString() !== valueNotNull.value) { - // Collab updates - if (options.props.value.collab) { - for (const tr of viewUpdate.transactions) { - if (tr.docChanged && !tr.annotation(Transaction.remote)) { - options.emit('collab', { - type: CollabEventType.UPDATE_TEXT, - path: options.props.value.collab.path, - updates: [{ changes: tr.changes, selection: tr.selection }], - }); - } - } - } - - // Model-value updates - options.emit('update:modelValue', viewUpdate.state.doc.toString()); - } + editorState.value = viewUpdate.state; - if (options.props.value.collab && (viewUpdate.selectionSet || viewUpdate.focusChanged)) { - // Collab awareness updates - const hasFocus = options.editorView.value?.hasFocus || false; - options.emit('collab', { - type: CollabEventType.AWARENESS, - path: options.props.value.collab.path, - focus: hasFocus, - selection: viewUpdate.state.selection, - }); - } + // Throttle update events + pendingViewUpdateEvents.value.push(viewUpdate); + sendUpdateEventsThrottled(); }), remoteSelection(), ] @@ -279,6 +307,7 @@ export function useMarkdownEditorBase(options: { editorActions.value.darkTheme(theme.current.value.dark); } else if (!newValue) { eventBusBeforeApplyRemoteTextChanges.off(onBeforeApplyRemoteTextChange); + sendUpdateEventsThrottled.flush(); } }, { immediate: true }); onBeforeUnmount(() => { @@ -334,7 +363,7 @@ export function useMarkdownEditorBase(options: { setRemoteClients.of(remoteClients) ] }) - }, { deep: true }); + }); function focus() { if (options.editorView.value) { diff --git a/packages/markdown/editor/spellcheck.ts b/packages/markdown/editor/spellcheck.ts index d498405e7..3d15dda23 100644 --- a/packages/markdown/editor/spellcheck.ts +++ b/packages/markdown/editor/spellcheck.ts @@ -82,7 +82,7 @@ export function spellcheck({ performSpellcheckRequest, performSpellcheckAddWordR }] : []), }; }); - }, {delay: 750}); + }, {delay: 1000}); }