From 840a3e44513e476b5f335f46dadad54499f9a5dd Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 30 Oct 2025 22:21:33 +0700 Subject: [PATCH] fix: optimize editor toolbar loading state check Signed-off-by: Alexander Onnikov --- .../src/components/extension/embed/embed.ts | 18 ++-- .../extension/embed/providers/drive.ts | 5 +- .../extension/embed/providers/youtube.ts | 2 +- .../src/components/extension/imageExt.ts | 8 +- .../components/extension/toolbar/toolbar.ts | 100 ++++++++++++++---- 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/plugins/text-editor-resources/src/components/extension/embed/embed.ts b/plugins/text-editor-resources/src/components/extension/embed/embed.ts index 4205149b809..9ce13bb8ca9 100644 --- a/plugins/text-editor-resources/src/components/extension/embed/embed.ts +++ b/plugins/text-editor-resources/src/components/extension/embed/embed.ts @@ -48,7 +48,11 @@ export interface EmbedNodeProvider { autoEmbedUrl?: (src: string) => boolean } -export type EmbedNodeView = (editor: Editor, root: HTMLDivElement) => EmbedNodeViewHandle | undefined +export type EmbedNodeView = ( + editor: Editor, + root: HTMLDivElement, + getPos: () => number +) => EmbedNodeViewHandle | undefined export type EmbedNodeProviderConstructor = (options: T) => EmbedNodeProvider export type EmbedCursor = ToolbarCursor @@ -101,7 +105,7 @@ export const EmbedNode = BaseEmbedNode.extend({ }, addNodeView () { - return ({ node, HTMLAttributes, editor }) => { + return ({ node, HTMLAttributes, editor, getPos }) => { const providerPromise = matchUrl(this.options.providers, node.attrs.src) const root = document.createElement('div') @@ -109,7 +113,8 @@ export const EmbedNode = BaseEmbedNode.extend({ root.setAttribute('data-embed-src', node.attrs.src) root.classList.add('embed-node') - setLoadingState(editor.view, root, true) + const pos = typeof getPos === 'function' ? getPos() : 0 + setLoadingState(editor.view, pos, true) root.setAttribute('block-editor-blur', 'true') let handle: EmbedNodeViewHandle | undefined @@ -117,13 +122,14 @@ export const EmbedNode = BaseEmbedNode.extend({ void providerPromise .then((view) => { view = view ?? StubEmbedNodeView - handle = view(editor, root) + handle = view(editor, root, getPos) if (handle !== undefined) { root.classList.add(`embed-${handle.name}`) } }) .finally(() => { - setLoadingState(editor.view, root, false) + const pos = typeof getPos === 'function' ? getPos() : 0 + setLoadingState(editor.view, pos, false) }) return { @@ -500,7 +506,7 @@ export function replacePreviewContent ( return tr } -const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLElement) => { +const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLDivElement, getPos: () => number) => { const hint = document.createElement('p') const hintIcon = hint.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg')) const hintSpan = hint.appendChild(document.createElement('span')) diff --git a/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts b/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts index c7c1e750a6d..a850be06660 100644 --- a/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts +++ b/plugins/text-editor-resources/src/components/extension/embed/providers/drive.ts @@ -59,9 +59,10 @@ export const DriveEmbedProvider: EmbedNodeProviderConstructor if (previewType === undefined) return - return (editor: Editor, root: HTMLDivElement) => { + return (editor: Editor, root: HTMLDivElement, getPos: () => number) => { const setLoading = (loading: boolean): void => { - setLoadingState(editor.view, root, loading) + const pos = typeof getPos === 'function' ? getPos() : 0 + setLoadingState(editor.view, pos, loading) } const renderer = new SvelteRenderer(FilePreview as any, { element: root, diff --git a/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts b/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts index 7fb0da57d95..0004c9e6856 100644 --- a/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts +++ b/plugins/text-editor-resources/src/components/extension/embed/providers/youtube.ts @@ -21,7 +21,7 @@ export const YoutubeEmbedProvider: EmbedNodeProviderConstructor { + return (editor: Editor, root: HTMLDivElement, getPos: () => number) => { root.setAttribute('data-block-toolbar-mouse-lock', 'true') const iframe = document.createElement('iframe') diff --git a/plugins/text-editor-resources/src/components/extension/imageExt.ts b/plugins/text-editor-resources/src/components/extension/imageExt.ts index 124fff20a49..617db0de120 100644 --- a/plugins/text-editor-resources/src/components/extension/imageExt.ts +++ b/plugins/text-editor-resources/src/components/extension/imageExt.ts @@ -151,7 +151,7 @@ export const ImageExtension = ImageNode.extend({ addNodeView () { const imageSrcCache = new Map() - return ({ view, node, HTMLAttributes }) => { + return ({ view, node, HTMLAttributes, getPos }) => { const container = document.createElement('div') const imgElement = document.createElement('img') container.append(imgElement) @@ -161,12 +161,14 @@ export const ImageExtension = ImageNode.extend({ 'data-align': node.attrs.align } - setLoadingState(view, container, true) + const pos = typeof getPos === 'function' ? getPos() : 0 + setLoadingState(view, pos, true) const setImageProps = (src: string | null, srcset: string | null): void => { if (src != null) imgElement.src = src if (srcset != null) imgElement.srcset = srcset void imgElement.decode().finally(() => { - setLoadingState(view, container, false) + const pos = typeof getPos === 'function' ? getPos() : 0 + setLoadingState(view, pos, false) }) } diff --git a/plugins/text-editor-resources/src/components/extension/toolbar/toolbar.ts b/plugins/text-editor-resources/src/components/extension/toolbar/toolbar.ts index fa1dd06fa55..9405e844f80 100644 --- a/plugins/text-editor-resources/src/components/extension/toolbar/toolbar.ts +++ b/plugins/text-editor-resources/src/components/extension/toolbar/toolbar.ts @@ -88,7 +88,7 @@ export interface SelectionCursorContext { export const ToolbarExtension = Extension.create({ addProseMirrorPlugins () { - return [ToolbarControlPlugin(this.editor, this.options)] + return [LoadingStatePlugin(), ToolbarControlPlugin(this.editor, this.options)] } }) @@ -98,6 +98,67 @@ export interface ToolbarControlTxMeta { providers?: Array> } +export const loadingStatePluginKey = new PluginKey('loadingState') + +export interface LoadingStatePluginState { + loadingNodes: Set +} + +export interface LoadingStateTxMeta { + setLoading?: { pos: number, loading: boolean } +} + +export function LoadingStatePlugin (): Plugin { + return new Plugin({ + key: loadingStatePluginKey, + + state: { + init: () => ({ + loadingNodes: new Set() + }), + + apply (tr, prevState) { + const meta = tr.getMeta(loadingStatePluginKey) as LoadingStateTxMeta | undefined + let loadingNodes = prevState.loadingNodes + + // Handle loading state changes + if (meta?.setLoading !== undefined) { + loadingNodes = new Set(loadingNodes) + if (meta.setLoading.loading) { + loadingNodes.add(meta.setLoading.pos) + } else { + loadingNodes.delete(meta.setLoading.pos) + } + } + + // Map positions through document changes + if (tr.docChanged && loadingNodes.size > 0) { + const mapped = new Set() + loadingNodes.forEach((pos) => { + try { + const mappedPos = tr.mapping.map(pos, -1) + // Verify position is still valid + if (mappedPos >= 0 && mappedPos < tr.doc.content.size) { + mapped.add(mappedPos) + } + } catch (e) { + // Position no longer valid, skip it + console.debug('LoadingStatePlugin: position no longer valid', pos) + } + }) + loadingNodes = mapped + } + + return { loadingNodes } + } + } + }) +} + +export function getLoadingState (state: EditorState): LoadingStatePluginState | undefined { + return loadingStatePluginKey.getState(state) as LoadingStatePluginState | undefined +} + export const toolbarPluginKey = new PluginKey('dynamicToolbar') export interface ToolbarControlPluginState { @@ -326,6 +387,7 @@ export function ToolbarControlPlugin (editor: Editor, options: ToolbarOptions): editor.on('transaction', ({ editor, transaction: tr }) => { const meta = tr.getMeta(toolbarPluginKey) as ToolbarControlTxMeta const loadingState = tr.getMeta('loadingState') as boolean | undefined + if (meta?.cursor !== undefined && !eqCursors(currCursor, meta.cursor)) { prevCursor = currCursor currCursor = meta.cursor !== null ? { ...meta.cursor } : null @@ -506,17 +568,22 @@ function updateCursorFromMouseEvent (view: EditorView, event: MouseEvent): void } function scanForLoadingState (view: EditorView, range: Range): boolean { - let isLoading = false - view.state.doc.nodesBetween(range.from, range.to, (node, pos) => { - const element = view.nodeDOM(pos) - if (!(element instanceof HTMLElement)) return + if (range.from > range.to || range.from < 0) { + return false + } - if (element.dataset.loading === 'true') { - isLoading = true - return false + const loadingState = getLoadingState(view.state) + if (loadingState === undefined || loadingState.loadingNodes.size === 0) { + return false + } + + for (const pos of loadingState.loadingNodes) { + if (pos >= range.from && pos <= range.to) { + return true } - }) - return isLoading + } + + return false } function scanForAnchor (view: EditorView, range: Range): HTMLElement | undefined { @@ -653,15 +720,12 @@ function minmax (value = 0, min = 0, max = 0): number { return Math.min(Math.max(value, min), max) } -export function setLoadingState (view: EditorView, element: HTMLElement, loading: boolean): void { - if (loading) { - element.setAttribute('data-loading', 'true') - } else { - element.removeAttribute('data-loading') - requestAnimationFrame(() => { - view.dispatch(view.state.tr.setMeta('loadingState', loading)) - }) +export function setLoadingState (view: EditorView, pos: number, loading: boolean): void { + const meta: LoadingStateTxMeta = { + setLoading: { pos, loading } } + + view.dispatch(view.state.tr.setMeta(loadingStatePluginKey, meta).setMeta('loadingState', loading)) } export const GeneralToolbarProvider: ToolbarProvider = {