diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 6e875256b9..b2693d7ec4 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -1,6 +1,7 @@ import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'; import { SlashMenuPluginKey } from '@extensions/slash-menu/slash-menu.js'; import { CellSelection } from 'prosemirror-tables'; +import { DecorationBridge } from './dom/DecorationBridge.js'; import type { EditorState, Transaction } from 'prosemirror-state'; import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model'; import type { Mapping } from 'prosemirror-transform'; @@ -101,6 +102,7 @@ import type { Fragment, } from '@superdoc/contracts'; import { extractHeaderFooterSpace as _extractHeaderFooterSpace } from '@superdoc/contracts'; +// TrackChangesBasePluginKey is used by #syncTrackedChangesPreferences and getTrackChangesPluginState. import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js'; // Collaboration cursor imports @@ -290,6 +292,10 @@ export class PresentationEditor extends EventEmitter { #htmlAnnotationMeasureAttempts = 0; #domPositionIndex = new DomPositionIndex(); #domIndexObserverManager: DomPositionIndexObserverManager | null = null; + /** Bridges external PM plugin decorations onto painted DOM elements. */ + #decorationBridge = new DecorationBridge(); + /** RAF handle for coalesced decoration sync scheduling. */ + #decorationSyncRafHandle: number | null = null; #rafHandle: number | null = null; #editorListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; #scrollHandler: (() => void) | null = null; @@ -422,6 +428,7 @@ export class PresentationEditor extends EventEmitter { getPainterHost: () => this.#painterHost, onRebuild: () => { this.#rebuildDomPositionIndex(); + this.#syncDecorations(); this.#selectionSync.requestRender({ immediate: true }); }, }); @@ -2172,6 +2179,16 @@ export class PresentationEditor extends EventEmitter { }, 'Layout RAF'); } + // Cancel pending decoration sync RAF + if (this.#decorationSyncRafHandle != null) { + safeCleanup(() => { + const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; + win.cancelAnimationFrame(this.#decorationSyncRafHandle!); + this.#decorationSyncRafHandle = null; + }, 'Decoration sync RAF'); + } + this.#decorationBridge.destroy(); + // Cancel pending cursor awareness update if (this.#cursorUpdateTimer !== null) { clearTimeout(this.#cursorUpdateTimer); @@ -2264,6 +2281,54 @@ export class PresentationEditor extends EventEmitter { } } + /** + * Runs a full decoration bridge sync: reads external plugin decorations and + * reconciles them onto painted DOM elements (add/update/remove). + * + * Called synchronously from post-paint and observer-rebuild paths where the + * DOM index is guaranteed to be fresh. + */ + #syncDecorations(): void { + const state = this.#editor?.view?.state; + if (!state) return; + + try { + this.#decorationBridge.sync(state, this.#domPositionIndex); + } catch (error) { + debugLog('warn', 'Decoration bridge sync failed', { error: String(error) }); + } + } + + /** + * Schedules a decoration sync on the next animation frame, coalesced so + * rapid transactions (cursor movement, selection changes) don't cause + * redundant work. + * + * Skips scheduling when: + * - A rerender is already pending (post-paint will sync). + * - No DecorationSet references have actually changed (identity check). + */ + #scheduleDecorationSync(): void { + // If a full rerender is pending, the post-paint path will sync. Skip. + if (this.#renderScheduled || this.#isRerendering) return; + + // Cheap identity check: bail if no DecorationSet references changed. + const state = this.#editor?.view?.state; + if (!state || !this.#decorationBridge.hasChanges(state)) return; + + // Already scheduled — RAF will handle it. + if (this.#decorationSyncRafHandle != null) return; + + const win = this.#visibleHost?.ownerDocument?.defaultView ?? window; + this.#decorationSyncRafHandle = win.requestAnimationFrame(() => { + this.#decorationSyncRafHandle = null; + // Re-check: a rerender may have been scheduled between when we queued + // this RAF and when it fires. The post-paint path will sync instead. + if (this.#renderScheduled || this.#isRerendering) return; + this.#syncDecorations(); + }); + } + #setupEditorListeners() { const handleUpdate = ({ transaction }: { transaction?: Transaction }) => { const trackedChangesChanged = this.#syncTrackedChangesPreferences(); @@ -2312,10 +2377,26 @@ export class PresentationEditor extends EventEmitter { this.#updateLocalAwarenessCursor(); this.#scheduleA11ySelectionAnnouncement(); }; + + // The 'transaction' event fires for ALL transactions (doc changes, + // selection changes, meta-only). The 'update' event only fires for + // docChanged transactions, and 'selectionUpdate' only for selection + // changes. A meta-only transaction (e.g., a custom command that sets + // plugin state without editing text) fires neither. + // + // We listen on 'transaction' so the decoration bridge picks up changes + // from any transaction type. The bridge's own identity check + RAF + // coalescing prevent unnecessary work. + const handleTransaction = () => { + this.#scheduleDecorationSync(); + }; + this.#editor.on('update', handleUpdate); this.#editor.on('selectionUpdate', handleSelection); + this.#editor.on('transaction', handleTransaction); this.#editorListeners.push({ event: 'update', handler: handleUpdate as (...args: unknown[]) => void }); this.#editorListeners.push({ event: 'selectionUpdate', handler: handleSelection as (...args: unknown[]) => void }); + this.#editorListeners.push({ event: 'transaction', handler: handleTransaction as (...args: unknown[]) => void }); // Listen for page style changes (e.g., margin adjustments via ruler). // These changes don't modify document content (docChanged === false), @@ -3163,6 +3244,7 @@ export class PresentationEditor extends EventEmitter { const painterPostStart = perfNow(); this.#applyVertAlignToLayout(); this.#rebuildDomPositionIndex(); + this.#syncDecorations(); this.#domIndexObserverManager?.resume(); const painterPostEnd = perfNow(); perfLog(`[Perf] painter.postPaint: ${(painterPostEnd - painterPostStart).toFixed(2)}ms`); diff --git a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts new file mode 100644 index 0000000000..8aeaa291bf --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts @@ -0,0 +1,748 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DecorationSet } from 'prosemirror-view'; +import { PluginKey } from 'prosemirror-state'; +import type { EditorState, Plugin } from 'prosemirror-state'; + +import { DecorationBridge } from './DecorationBridge.js'; +import { DomPositionIndex } from './DomPositionIndex.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Minimal decoration attrs for concise test authoring. */ +type DecoAttrs = { from: number; to: number; class?: string; attrs?: Record }; + +/** Creates a mock inline ProseMirror decoration. */ +const mockDecoration = (from: number, to: number, attrs: Record) => ({ + inline: true, + from, + to, + type: { attrs }, +}); + +/** Creates a DecorationSet-compatible object from a list of decoration descriptors. */ +const mockDecorationSet = (items: DecoAttrs[]): DecorationSet => { + const decorations = items.map(({ from, to, class: cls, attrs }) => + mockDecoration(from, to, { ...(cls ? { class: cls } : {}), ...attrs }), + ); + const set = Object.create(DecorationSet.prototype); + set.find = () => decorations; + return set; +}; + +/** Creates a mock external plugin with a decoration set. */ +const externalPlugin = (keyName: string, items: DecoAttrs[]): Plugin => { + const set = mockDecorationSet(items); + return { + key: `${keyName}$1`, + spec: { key: new PluginKey(keyName) }, + props: { decorations: () => set }, + } as unknown as Plugin; +}; + +/** Creates a mock external plugin whose decoration set can be swapped. */ +const mutableExternalPlugin = (keyName: string) => { + let currentSet: DecorationSet = DecorationSet.empty; + const plugin = { + key: `${keyName}$1`, + spec: { key: new PluginKey(keyName) }, + props: { decorations: () => currentSet }, + } as unknown as Plugin; + const setDecorations = (items: DecoAttrs[]) => { + currentSet = items.length > 0 ? mockDecorationSet(items) : DecorationSet.empty; + }; + return { plugin, setDecorations }; +}; + +/** + * Builds a minimal mock EditorState from a plugin list. + * Only the fields used by DecorationBridge are populated. + */ +const mockState = (plugins: Plugin[]): EditorState => + ({ + plugins, + doc: { content: { size: 1000 } }, + }) as unknown as EditorState; + +/** + * Creates a real DomPositionIndex backed by a container element. + * Elements appended to the container with `data-pm-start`/`data-pm-end` + * become queryable after calling `rebuild()`. + */ +const createIndex = () => { + const container = document.createElement('div'); + const index = new DomPositionIndex(); + + const addSpan = (start: number, end: number, text = 'x'): HTMLSpanElement => { + const span = document.createElement('span'); + span.dataset.pmStart = String(start); + span.dataset.pmEnd = String(end); + span.textContent = text; + container.appendChild(span); + return span; + }; + + const rebuild = () => index.rebuild(container); + + return { container, index, addSpan, rebuild }; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DecorationBridge', () => { + let bridge: DecorationBridge; + + beforeEach(() => { + bridge = new DecorationBridge(); + }); + + afterEach(() => { + bridge.destroy(); + }); + + // ----------------------------------------------------------------------- + // Apply — fresh elements + // ----------------------------------------------------------------------- + + describe('applying decorations to fresh elements', () => { + it('applies a single class to an element within the decoration range', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + + expect(span.classList.contains('hl')).toBe(true); + }); + + it('applies multiple classes from a space-separated class string', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, class: 'hl-a hl-b hl-c' }]); + bridge.sync(mockState([plugin]), index); + + expect(span.classList.contains('hl-a')).toBe(true); + expect(span.classList.contains('hl-b')).toBe(true); + expect(span.classList.contains('hl-c')).toBe(true); + }); + + it('applies data-* attributes from decorations', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [ + { from: 5, to: 15, attrs: { 'data-id': '42', 'data-type': 'clause' } }, + ]); + bridge.sync(mockState([plugin]), index); + + expect(span.getAttribute('data-id')).toBe('42'); + expect(span.getAttribute('data-type')).toBe('clause'); + }); + + it('applies both classes and data attributes together', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, class: 'hl', attrs: { 'data-id': '1' } }]); + bridge.sync(mockState([plugin]), index); + + expect(span.classList.contains('hl')).toBe(true); + expect(span.getAttribute('data-id')).toBe('1'); + }); + + it('applies decorations across multiple elements spanning the range', () => { + const { index, addSpan, rebuild } = createIndex(); + const span1 = addSpan(5, 12); + const span2 = addSpan(12, 20); + const span3 = addSpan(20, 25); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 25, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + + expect(span1.classList.contains('hl')).toBe(true); + expect(span2.classList.contains('hl')).toBe(true); + expect(span3.classList.contains('hl')).toBe(true); + }); + + it('does not apply decorations to elements outside the range', () => { + const { index, addSpan, rebuild } = createIndex(); + const before = addSpan(1, 4); + const within = addSpan(5, 15); + const after = addSpan(16, 25); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + + expect(before.classList.contains('hl')).toBe(false); + expect(within.classList.contains('hl')).toBe(true); + expect(after.classList.contains('hl')).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Removal — stale decorations cleaned up + // ----------------------------------------------------------------------- + + describe('removing stale decorations', () => { + it('removes classes when decorations are cleared', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('hl')).toBe(true); + + // Clear decorations and re-sync. + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('hl')).toBe(false); + }); + + it('removes data attributes when decorations are cleared', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { 'data-id': '42' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-id')).toBe('42'); + + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-id')).toBeNull(); + }); + + it('removes classes from elements that leave a shrinking range', () => { + const { index, addSpan, rebuild } = createIndex(); + const span1 = addSpan(5, 12); + const span2 = addSpan(12, 20); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + // Initially covers both spans. + setDecorations([{ from: 5, to: 20, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + expect(span1.classList.contains('hl')).toBe(true); + expect(span2.classList.contains('hl')).toBe(true); + + // Shrink range to only cover span1. + setDecorations([{ from: 5, to: 12, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + expect(span1.classList.contains('hl')).toBe(true); + expect(span2.classList.contains('hl')).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Update — range/attr changes + // ----------------------------------------------------------------------- + + describe('updating decorations on change', () => { + it('updates classes when decoration class changes', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'old-class' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('old-class')).toBe(true); + + setDecorations([{ from: 5, to: 15, class: 'new-class' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('old-class')).toBe(false); + expect(span.classList.contains('new-class')).toBe(true); + }); + + it('updates data attribute values', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { 'data-id': 'v1' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-id')).toBe('v1'); + + setDecorations([{ from: 5, to: 15, attrs: { 'data-id': 'v2' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-id')).toBe('v2'); + }); + + it('removes stale data attributes when key is no longer present', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { 'data-a': '1', 'data-b': '2' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-a')).toBe('1'); + expect(span.getAttribute('data-b')).toBe('2'); + + // Only data-a remains. + setDecorations([{ from: 5, to: 15, attrs: { 'data-a': '1' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-a')).toBe('1'); + expect(span.getAttribute('data-b')).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Merge semantics + // ----------------------------------------------------------------------- + + describe('merge semantics for overlapping decorations', () => { + it('unions classes from overlapping decorations', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [ + { from: 5, to: 20, class: 'outer' }, + { from: 3, to: 15, class: 'inner' }, + ]); + bridge.sync(mockState([plugin]), index); + + expect(span.classList.contains('outer')).toBe(true); + expect(span.classList.contains('inner')).toBe(true); + }); + + it('last plugin wins for conflicting data-* keys', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const pluginA = externalPlugin('first', [{ from: 5, to: 15, attrs: { 'data-owner': 'plugin-a' } }]); + const pluginB = externalPlugin('second', [{ from: 5, to: 15, attrs: { 'data-owner': 'plugin-b' } }]); + // pluginB is later in the array, so it wins. + bridge.sync(mockState([pluginA, pluginB]), index); + + expect(span.getAttribute('data-owner')).toBe('plugin-b'); + }); + }); + + // ----------------------------------------------------------------------- + // Style property handling + // ----------------------------------------------------------------------- + + describe('style property handling', () => { + it('applies individual style properties from decoration style attribute', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, attrs: { style: 'background-color: yellow;' } }]); + bridge.sync(mockState([plugin]), index); + + expect(span.style.getPropertyValue('background-color')).toBe('yellow'); + }); + + it('applies multiple style properties from a single decoration', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [ + { + from: 5, + to: 15, + attrs: { style: 'background-color: rgba(0, 178, 169, 0.3); border: 1.5px solid rgb(229, 57, 53);' }, + }, + ]); + bridge.sync(mockState([plugin]), index); + + expect(span.style.getPropertyValue('background-color')).toBe('rgba(0, 178, 169, 0.3)'); + }); + + it('applies style properties alongside class and data-* from the same decoration', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [ + { from: 5, to: 15, class: 'hl', attrs: { style: 'color: red;', 'data-id': '1' } }, + ]); + bridge.sync(mockState([plugin]), index); + + expect(span.classList.contains('hl')).toBe(true); + expect(span.getAttribute('data-id')).toBe('1'); + expect(span.style.getPropertyValue('color')).toBe('red'); + }); + + it('removes style properties when decorations are cleared', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { style: 'background-color: yellow;' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('background-color')).toBe('yellow'); + + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('background-color')).toBe(''); + }); + + it('updates style properties when decoration style changes', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { style: 'color: red;' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('color')).toBe('red'); + + setDecorations([{ from: 5, to: 15, attrs: { style: 'color: blue;' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('color')).toBe('blue'); + }); + + it('removes stale style properties when they disappear from decorations', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { style: 'color: red; font-weight: bold;' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('color')).toBe('red'); + expect(span.style.getPropertyValue('font-weight')).toBe('bold'); + + // Only color remains. + setDecorations([{ from: 5, to: 15, attrs: { style: 'color: red;' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('color')).toBe('red'); + expect(span.style.getPropertyValue('font-weight')).toBe(''); + }); + + it('does not clobber painter-owned style properties', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + span.style.setProperty('font-size', '14px'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { style: 'background-color: yellow;' } }]); + bridge.sync(mockState([plugin]), index); + + // Bridge-owned property is applied. + expect(span.style.getPropertyValue('background-color')).toBe('yellow'); + // Painter-owned property is untouched. + expect(span.style.getPropertyValue('font-size')).toBe('14px'); + + // Clear bridge decorations — painter-owned property must survive. + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('background-color')).toBe(''); + expect(span.style.getPropertyValue('font-size')).toBe('14px'); + }); + + it('ignores empty style strings', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, attrs: { style: ' ' } }]); + bridge.sync(mockState([plugin]), index); + + expect(span.getAttribute('style')).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Security filtering + // ----------------------------------------------------------------------- + + describe('security filtering', () => { + it('ignores non-data-* attributes like id, onclick, href', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [ + { from: 5, to: 15, attrs: { id: 'bad', onclick: 'alert(1)', href: 'http://evil.com', 'data-ok': 'yes' } }, + ]); + bridge.sync(mockState([plugin]), index); + + expect(span.getAttribute('id')).toBeNull(); + expect(span.getAttribute('onclick')).toBeNull(); + expect(span.getAttribute('href')).toBeNull(); + expect(span.getAttribute('data-ok')).toBe('yes'); + }); + }); + + // ----------------------------------------------------------------------- + // Repaint safety — fresh elements + // ----------------------------------------------------------------------- + + describe('repaint safety', () => { + it('applies decorations to fresh elements that replace old ones', () => { + const { container, index, addSpan, rebuild } = createIndex(); + const oldSpan = addSpan(5, 15); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + expect(oldSpan.classList.contains('hl')).toBe(true); + + // Simulate DomPainter repaint: remove old element, add new one. + container.removeChild(oldSpan); + const newSpan = addSpan(5, 15); + rebuild(); + + bridge.sync(mockState([plugin]), index); + expect(newSpan.classList.contains('hl')).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // Internal plugin exclusion + // ----------------------------------------------------------------------- + + describe('internal plugin exclusion', () => { + it('excludes plugins with known internal key prefixes', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + // Simulate an internal plugin with an unexported key prefix. + const internalPlugin = { + key: 'placeholder$1', + spec: { key: new PluginKey('placeholder') }, + props: { decorations: () => mockDecorationSet([{ from: 5, to: 15, class: 'internal' }]) }, + } as unknown as Plugin; + + bridge.sync(mockState([internalPlugin]), index); + + expect(span.classList.contains('internal')).toBe(false); + }); + + it('excludes the built-in search plugin (unexported key)', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const searchPlugin = { + key: 'search$1', + spec: { key: new PluginKey('search') }, + props: { decorations: () => mockDecorationSet([{ from: 5, to: 15, class: 'search-match' }]) }, + } as unknown as Plugin; + + bridge.sync(mockState([searchPlugin]), index); + + expect(span.classList.contains('search-match')).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // hasChanges — identity check + // ----------------------------------------------------------------------- + + describe('hasChanges identity check', () => { + it('returns true when DecorationSet reference changes', () => { + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'hl' }]); + + const { index, addSpan, rebuild } = createIndex(); + addSpan(5, 15); + rebuild(); + + const state = mockState([plugin]); + // First sync captures the initial set. + bridge.sync(state, index); + + // Same reference → no changes. + expect(bridge.hasChanges(state)).toBe(false); + + // New reference → has changes. + setDecorations([{ from: 5, to: 15, class: 'hl-new' }]); + expect(bridge.hasChanges(state)).toBe(true); + }); + + it('returns false when no eligible plugins exist and none were synced before', () => { + const state = mockState([]); + expect(bridge.hasChanges(state)).toBe(false); + }); + + it('returns true when eligible plugins drop to zero but state was previously synced', () => { + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'hl' }]); + + const { index, addSpan, rebuild } = createIndex(); + addSpan(5, 15); + rebuild(); + + // Sync once to populate prevDecorationSets. + bridge.sync(mockState([plugin]), index); + expect(bridge.hasChanges(mockState([plugin]))).toBe(false); + + // All plugins removed — bridge should detect stale state needs cleanup. + expect(bridge.hasChanges(mockState([]))).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + describe('edge cases', () => { + it('handles empty decoration set without errors', () => { + const { index, addSpan, rebuild } = createIndex(); + addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', []); + bridge.sync(mockState([plugin]), index); + // No errors thrown, no classes applied. + }); + + it('handles decorations with no class or attributes', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15 }]); + bridge.sync(mockState([plugin]), index); + + expect(span.classList.length).toBe(0); + }); + + it('does not touch painter-owned classes during removal', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + span.classList.add('painter-owned'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'bridge-class' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('painter-owned')).toBe(true); + expect(span.classList.contains('bridge-class')).toBe(true); + + // Clear bridge decorations — painter-owned class must survive. + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('painter-owned')).toBe(true); + expect(span.classList.contains('bridge-class')).toBe(false); + }); + + it('restores painter-owned class when decoration uses the same class name', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + span.classList.add('shared-class'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, class: 'shared-class' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('shared-class')).toBe(true); + + // Clear — painter-owned class must still be present. + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('shared-class')).toBe(true); + }); + + it('does not touch painter-owned data attributes during removal', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + span.setAttribute('data-painter', 'yes'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { 'data-bridge': 'yes' } }]); + bridge.sync(mockState([plugin]), index); + + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-painter')).toBe('yes'); + expect(span.getAttribute('data-bridge')).toBeNull(); + }); + + it('restores painter-owned data-attr value when decoration overwrites it', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + span.setAttribute('data-id', 'painter-value'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { 'data-id': 'bridge-value' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-id')).toBe('bridge-value'); + + // Clear — original painter value must be restored. + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.getAttribute('data-id')).toBe('painter-value'); + }); + + it('restores painter-owned style property when decoration overwrites it', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + span.style.setProperty('background-color', 'white'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('highlight'); + setDecorations([{ from: 5, to: 15, attrs: { style: 'background-color: yellow;' } }]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('background-color')).toBe('yellow'); + + // Clear — original painter value must be restored. + setDecorations([]); + bridge.sync(mockState([plugin]), index); + expect(span.style.getPropertyValue('background-color')).toBe('white'); + }); + }); + + // ----------------------------------------------------------------------- + // Plugin cache invalidation + // ----------------------------------------------------------------------- + + describe('plugin cache invalidation', () => { + it('picks up newly registered plugins', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + // Start with no plugins. + bridge.sync(mockState([]), index); + expect(span.classList.contains('hl')).toBe(false); + + // Register a new plugin (simulates Editor.registerPlugin). + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('hl')).toBe(true); + }); + + it('stops syncing decorations from unregistered plugins', () => { + const { index, addSpan, rebuild } = createIndex(); + const span = addSpan(5, 15); + rebuild(); + + const plugin = externalPlugin('highlight', [{ from: 5, to: 15, class: 'hl' }]); + bridge.sync(mockState([plugin]), index); + expect(span.classList.contains('hl')).toBe(true); + + // Unregister (new plugins array without the plugin). + bridge.sync(mockState([]), index); + expect(span.classList.contains('hl')).toBe(false); + }); + }); +}); diff --git a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts new file mode 100644 index 0000000000..6fdad41d76 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts @@ -0,0 +1,554 @@ +import { DecorationSet } from 'prosemirror-view'; +import type { EditorState, Plugin, PluginKey } from 'prosemirror-state'; + +import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/index.js'; +import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; +import { customSearchHighlightsKey } from '@extensions/search/search.js'; +import { AiPluginKey } from '@extensions/ai/ai-plugin.js'; +import { CustomSelectionPluginKey } from '@extensions/custom-selection/custom-selection.js'; +import { LinkedStylesPluginKey } from '@extensions/linked-styles/plugin.js'; +import { NodeResizerKey } from '@extensions/noderesizer/noderesizer.js'; + +import type { DomPositionIndex, DomPositionIndexEntry } from './DomPositionIndex.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Tracks what the bridge has applied to a single DOM element. + * Used for diffing on the next sync pass so stale state is removed cleanly. + * + * Prior-value maps record what existed on the element BEFORE the bridge touched + * it. On removal, the bridge restores these values instead of blindly deleting, + * so painter-owned properties are never clobbered. + */ +interface AppliedState { + classes: Set; + dataAttrs: Map; + /** Individual CSS properties applied by the bridge (property name → value). */ + styleProps: Map; + + /** Classes that existed on the element before the bridge added them. */ + priorClasses: Set; + /** Data-attr values that existed before the bridge set them (null = attr did not exist). */ + priorDataAttrs: Map; + /** Style property values before the bridge set them (empty string = prop did not exist). */ + priorStyleProps: Map; +} + +/** + * Desired decoration payload for a single DOM element, accumulated across all + * eligible plugins before being committed to the DOM. + */ +interface DesiredState { + classes: Set; + dataAttrs: Map; + /** Individual CSS properties desired by decorations (property name → value). */ + styleProps: Map; +} + +// --------------------------------------------------------------------------- +// Internal plugin exclusion +// --------------------------------------------------------------------------- + +/** + * Exported plugin keys whose decorations are rendered by the painter or other + * internal systems. Matched by reference identity (`plugin.spec.key === ref`). + */ +const EXCLUDED_PLUGIN_KEY_REF_LIST: PluginKey[] = [ + TrackChangesBasePluginKey, + CommentsPluginKey, + customSearchHighlightsKey, + AiPluginKey, + CustomSelectionPluginKey, + LinkedStylesPluginKey, + NodeResizerKey, +]; + +const EXCLUDED_PLUGIN_KEY_REFS: ReadonlySet = new Set([...EXCLUDED_PLUGIN_KEY_REF_LIST]); + +/** + * String prefixes for internal plugins whose keys are NOT exported. + * ProseMirror sets `plugin.key` to `'$'`, so we match the + * prefix before the `$` separator. + * + * | Prefix | Source file | Why excluded | + * |-------------------|------------------------------------------------|-------------------------------| + * | placeholder | extensions/placeholder/placeholder.js | Editor chrome (empty-state) | + * | tabPlugin | extensions/tab/tab.js | Layout-level tab sizing | + * | dropcapPlugin | extensions/paragraph/dropcapPlugin.js | Layout-level margin adjust | + * | ImagePosition | extensions/image/imageHelpers/imagePositionPlugin.js | Layout-level image positioning | + * | ImageRegistration | extensions/image/imageHelpers/imageRegistrationPlugin.js | Upload placeholder chrome | + * | search | extensions/search/prosemirror-search-patched.js | Painter handles search highlights | + * | yjs-cursor | y-prosemirror collaboration cursor plugin | Remote cursor UI layer | + */ +const EXCLUDED_PLUGIN_KEY_PREFIXES: readonly string[] = [ + 'placeholder', + 'tabPlugin', + 'dropcapPlugin', + 'ImagePosition', + 'ImageRegistration', + 'search', + 'yjs-cursor', +]; + +// --------------------------------------------------------------------------- +// DecorationBridge +// --------------------------------------------------------------------------- + +/** + * Bridges ProseMirror plugin decorations onto DomPainter-rendered elements. + * + * The layout engine renders into its own DOM tree, so PM decorations (which + * target the hidden contenteditable) are invisible to the user. This bridge + * reads inline decoration `class` and `data-*` attributes from eligible + * external plugins and mirrors them onto the painted elements, with a full + * add/update/remove reconciliation lifecycle. + * + * ## Ownership boundary + * The bridge tracks exactly which classes and data-attributes it has applied + * via a WeakMap keyed by DOM element. It never touches classes or attributes + * owned by the painter or other systems. + * + * ## Merge semantics + * - **Classes**: union of all classes from all overlapping decorations. + * - **`data-*` attributes**: later plugin in `state.plugins` order wins for + * the same key on the same element. + * - **`style`**: parsed into individual CSS properties and applied via + * `el.style.setProperty()` so painter-owned properties are never clobbered. + * Later plugin wins per CSS property name. + */ +export class DecorationBridge { + /** Tracks bridge-owned state per painted DOM element. */ + #applied = new WeakMap(); + + /** Cached list of plugins eligible for bridging. */ + #eligiblePlugins: Plugin[] = []; + + /** Identity snapshot of `state.plugins` when `#eligiblePlugins` was last built. */ + #pluginListSnapshot: readonly Plugin[] = []; + + /** Last-seen DecorationSet per plugin, for cheap identity-based skip. */ + #prevDecorationSets = new Map(); + + /** True if the last sync had at least one eligible plugin. Used to detect the → 0 transition. */ + #hadEligiblePlugins = false; + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Runs a full reconciliation pass: reads decoration state from eligible PM + * plugins, maps them to painted DOM via the position index, and diffs + * against previously applied state. + * + * @returns `true` if any DOM mutations were made, `false` if skipped. + */ + sync(state: EditorState, domIndex: DomPositionIndex): boolean { + this.#refreshEligiblePlugins(state); + + const docSize = state.doc.content.size; + const desired = + this.#eligiblePlugins.length > 0 + ? this.#collectDesiredState(state, domIndex, docSize) + : new Map(); + + this.#hadEligiblePlugins = this.#eligiblePlugins.length > 0; + return this.#reconcile(desired, domIndex, docSize); + } + + /** + * Checks whether any eligible plugin's DecorationSet has changed since the + * last sync. Use this as a cheap gate before calling `sync()`. + * + * @returns `true` if at least one DecorationSet reference changed. + */ + hasChanges(state: EditorState): boolean { + this.#refreshEligiblePlugins(state); + + // Transition from some plugins → zero: stale bridge state needs cleanup. + if (this.#eligiblePlugins.length === 0) { + return this.#hadEligiblePlugins; + } + + for (const plugin of this.#eligiblePlugins) { + const currentSet = this.#getDecorationSet(plugin, state); + if (currentSet !== this.#prevDecorationSets.get(plugin)) return true; + } + return false; + } + + /** + * Removes all bridge-owned classes and data-attributes from the DOM. + * Called during teardown. + */ + destroy(): void { + this.#eligiblePlugins = []; + this.#pluginListSnapshot = []; + this.#prevDecorationSets.clear(); + this.#hadEligiblePlugins = false; + // WeakMap entries are garbage collected with their elements. + } + + // ------------------------------------------------------------------------- + // Plugin filtering + // ------------------------------------------------------------------------- + + /** + * Rebuilds the eligible plugin list when the plugin array has changed. + * Uses a two-tier strategy: + * 1. Exclude by exported PluginKey reference (7 known internal keys). + * 2. Exclude by plugin.key string prefix (5 unexported internal keys). + */ + #refreshEligiblePlugins(state: EditorState): void { + if (state.plugins === this.#pluginListSnapshot) return; + + this.#pluginListSnapshot = state.plugins; + this.#eligiblePlugins = state.plugins.filter((plugin) => { + if (!plugin.props.decorations) return false; + if (this.#isExcludedByKeyRef(plugin)) return false; + if (this.#isExcludedByKeyPrefix(plugin)) return false; + return true; + }); + + // Prune stale entries from the identity map. + const eligibleSet = new Set(this.#eligiblePlugins); + for (const key of this.#prevDecorationSets.keys()) { + if (!eligibleSet.has(key)) this.#prevDecorationSets.delete(key); + } + } + + /** Checks if a plugin's key matches one of the exported internal PluginKey references. */ + #isExcludedByKeyRef(plugin: Plugin): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specKey = (plugin as any).spec?.key; + return specKey != null && EXCLUDED_PLUGIN_KEY_REFS.has(specKey); + } + + /** Checks if a plugin's key string starts with a known internal prefix. */ + #isExcludedByKeyPrefix(plugin: Plugin): boolean { + // ProseMirror formats plugin.key as '$'. + const keyString: string = (plugin as unknown as Record).key ?? ''; + return EXCLUDED_PLUGIN_KEY_PREFIXES.some((prefix) => keyString === prefix || keyString.startsWith(`${prefix}$`)); + } + + // ------------------------------------------------------------------------- + // Decoration collection + // ------------------------------------------------------------------------- + + /** + * Reads inline decorations from all eligible plugins and accumulates + * desired class/data-attr state per painted DOM element. + * + * Returns a Map of DOM element → desired state. Elements that are in the + * position index but have no decorations are NOT included (they'll be + * handled as removals in reconcile). + */ + #collectDesiredState( + state: EditorState, + domIndex: DomPositionIndex, + docSize: number, + ): Map { + const desired = new Map(); + + for (const plugin of this.#eligiblePlugins) { + const decorationSet = this.#getDecorationSet(plugin, state); + this.#prevDecorationSets.set(plugin, decorationSet); + if (decorationSet === DecorationSet.empty) continue; + + const decorations = decorationSet.find(0, docSize); + for (const decoration of decorations) { + if (!this.#isInlineDecoration(decoration)) continue; + + const attrs = this.#extractSafeAttrs(decoration); + if (attrs.classes.length === 0 && attrs.dataEntries.length === 0 && attrs.styleEntries.length === 0) continue; + + const entries = domIndex.findEntriesInRange(decoration.from, decoration.to); + for (const entry of entries) { + const state = this.#getOrCreateDesired(desired, entry.el); + for (const cls of attrs.classes) state.classes.add(cls); + for (const [key, value] of attrs.dataEntries) state.dataAttrs.set(key, value); + for (const [prop, value] of attrs.styleEntries) state.styleProps.set(prop, value); + } + } + } + + return desired; + } + + /** Safely retrieves the DecorationSet from a plugin, returning empty on failure. */ + #getDecorationSet(plugin: Plugin, state: EditorState): DecorationSet { + try { + const result = plugin.props.decorations?.call(plugin, state); + return result instanceof DecorationSet ? result : DecorationSet.empty; + } catch { + return DecorationSet.empty; + } + } + + /** Checks if a decoration is an inline decoration (not widget or node). */ + #isInlineDecoration(decoration: { from: number; to: number }): boolean { + // @ts-expect-error - ProseMirror's internal `inline` flag is not typed. + return decoration.inline === true; + } + + /** + * Extracts bridge-safe attributes from a decoration: + * - `class` is split into individual class names. + * - `data-*` attributes are preserved. + * - `style` is parsed into individual CSS properties (property-level, not raw string). + * - All other attributes (id, onclick, href, etc.) are ignored for security. + */ + #extractSafeAttrs(decoration: { from: number; to: number }): { + classes: string[]; + dataEntries: [string, string][]; + styleEntries: [string, string][]; + } { + // @ts-expect-error - ProseMirror's `type.attrs` is not in the public types. + const raw: Record = decoration.type?.attrs ?? {}; + + const classes = typeof raw.class === 'string' ? raw.class.split(/\s+/).filter((c: string) => c.length > 0) : []; + + const dataEntries: [string, string][] = []; + for (const [key, value] of Object.entries(raw)) { + if (key === 'class' || key === 'style') continue; + if (!key.startsWith('data-')) continue; + if (typeof value !== 'string') continue; + dataEntries.push([key, value]); + } + + const styleEntries: [string, string][] = + typeof raw.style === 'string' ? DecorationBridge.#parseStyleString(raw.style) : []; + + return { classes, dataEntries, styleEntries }; + } + + /** + * Parses a CSS style string into individual [property, value] pairs. + * Uses a temporary element so the browser handles shorthand expansion, + * vendor prefixes, and validation. + */ + static #parseStyleString(cssText: string): [string, string][] { + if (!cssText.trim()) return []; + + const temp = document.createElement('span'); + temp.style.cssText = cssText; + + const entries: [string, string][] = []; + for (let i = 0; i < temp.style.length; i++) { + const prop = temp.style.item(i); + const value = temp.style.getPropertyValue(prop); + if (prop && value) entries.push([prop, value]); + } + return entries; + } + + /** Gets or creates the desired state for an element. */ + #getOrCreateDesired(map: Map, el: HTMLElement): DesiredState { + let state = map.get(el); + if (!state) { + state = { classes: new Set(), dataAttrs: new Map(), styleProps: new Map() }; + map.set(el, state); + } + return state; + } + + // ------------------------------------------------------------------------- + // Reconciliation + // ------------------------------------------------------------------------- + + /** + * Diffs desired state against previously applied state and updates the DOM. + * + * Three cases per element: + * 1. **New element** (in desired, not in applied): apply all desired state. + * 2. **Updated element** (in both): add new, remove stale. + * 3. **Removed element** (in applied, not in desired): remove all bridge state. + * + * Case 3 is handled by scanning the position index for elements that have + * applied state but no desired state. + */ + #reconcile(desired: Map, domIndex: DomPositionIndex, docSize: number): boolean { + let mutated = false; + + // Apply or update: iterate elements that should have decorations. + for (const [el, desiredState] of desired) { + const applied = this.#applied.get(el); + + if (!applied) { + // Case 1: fresh element, no prior state. + this.#applyFresh(el, desiredState); + mutated = true; + } else { + // Case 2: element has prior state — diff and update. + if (this.#applyDiff(el, applied, desiredState)) mutated = true; + } + } + + // Case 3: remove stale state from elements no longer covered. + // We scan all indexed elements and check for orphaned applied state. + const allEntries = docSize > 0 ? domIndex.findEntriesInRange(0, docSize) : []; + for (const entry of allEntries) { + if (desired.has(entry.el)) continue; + + const applied = this.#applied.get(entry.el); + if (!applied) continue; + + this.#removeAll(entry.el, applied); + mutated = true; + } + + return mutated; + } + + /** + * Applies decoration state to a fresh element (no prior bridge state). + */ + #applyFresh(el: HTMLElement, desired: DesiredState): void { + const tracked: AppliedState = { + classes: new Set(), + dataAttrs: new Map(), + styleProps: new Map(), + priorClasses: new Set(), + priorDataAttrs: new Map(), + priorStyleProps: new Map(), + }; + + for (const cls of desired.classes) { + if (el.classList.contains(cls)) tracked.priorClasses.add(cls); + el.classList.add(cls); + tracked.classes.add(cls); + } + for (const [key, value] of desired.dataAttrs) { + const prior = el.getAttribute(key); + if (prior !== null) tracked.priorDataAttrs.set(key, prior); + el.setAttribute(key, value); + tracked.dataAttrs.set(key, value); + } + for (const [prop, value] of desired.styleProps) { + const prior = el.style.getPropertyValue(prop); + if (prior) tracked.priorStyleProps.set(prop, prior); + el.style.setProperty(prop, value); + tracked.styleProps.set(prop, value); + } + + this.#applied.set(el, tracked); + } + + /** + * Diffs desired vs applied state and makes minimal DOM updates. + * @returns `true` if any DOM mutations were made. + */ + #applyDiff(el: HTMLElement, applied: AppliedState, desired: DesiredState): boolean { + let mutated = false; + + // Classes: add new, remove stale (restoring painter-owned on removal). + for (const cls of desired.classes) { + if (!applied.classes.has(cls)) { + if (el.classList.contains(cls)) applied.priorClasses.add(cls); + el.classList.add(cls); + applied.classes.add(cls); + mutated = true; + } + } + for (const cls of applied.classes) { + if (!desired.classes.has(cls)) { + if (!applied.priorClasses.has(cls)) { + el.classList.remove(cls); + } + applied.priorClasses.delete(cls); + applied.classes.delete(cls); + mutated = true; + } + } + + // Data attributes: add/update new, remove stale (restoring prior values). + for (const [key, value] of desired.dataAttrs) { + if (applied.dataAttrs.get(key) !== value) { + if (!applied.dataAttrs.has(key)) { + const prior = el.getAttribute(key); + if (prior !== null) applied.priorDataAttrs.set(key, prior); + } + el.setAttribute(key, value); + applied.dataAttrs.set(key, value); + mutated = true; + } + } + for (const key of applied.dataAttrs.keys()) { + if (!desired.dataAttrs.has(key)) { + const prior = applied.priorDataAttrs.get(key); + if (prior != null) { + el.setAttribute(key, prior); + } else { + el.removeAttribute(key); + } + applied.priorDataAttrs.delete(key); + applied.dataAttrs.delete(key); + mutated = true; + } + } + + // Style properties: add/update new, remove stale (restoring prior values). + for (const [prop, value] of desired.styleProps) { + if (applied.styleProps.get(prop) !== value) { + if (!applied.styleProps.has(prop)) { + const prior = el.style.getPropertyValue(prop); + if (prior) applied.priorStyleProps.set(prop, prior); + } + el.style.setProperty(prop, value); + applied.styleProps.set(prop, value); + mutated = true; + } + } + for (const prop of applied.styleProps.keys()) { + if (!desired.styleProps.has(prop)) { + const prior = applied.priorStyleProps.get(prop); + if (prior) { + el.style.setProperty(prop, prior); + } else { + el.style.removeProperty(prop); + } + applied.priorStyleProps.delete(prop); + applied.styleProps.delete(prop); + mutated = true; + } + } + + // If all bridge state was removed, clean up the WeakMap entry. + if (applied.classes.size === 0 && applied.dataAttrs.size === 0 && applied.styleProps.size === 0) { + this.#applied.delete(el); + } + + return mutated; + } + + /** + * Removes all bridge-owned state from an element. + */ + #removeAll(el: HTMLElement, applied: AppliedState): void { + for (const cls of applied.classes) { + if (!applied.priorClasses.has(cls)) { + el.classList.remove(cls); + } + } + for (const key of applied.dataAttrs.keys()) { + const prior = applied.priorDataAttrs.get(key); + if (prior != null) { + el.setAttribute(key, prior); + } else { + el.removeAttribute(key); + } + } + for (const prop of applied.styleProps.keys()) { + const prior = applied.priorStyleProps.get(prop); + if (prior) { + el.style.setProperty(prop, prior); + } else { + el.style.removeProperty(prop); + } + } + this.#applied.delete(el); + } +} diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts new file mode 100644 index 0000000000..208bde15c1 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts @@ -0,0 +1,583 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DecorationSet } from 'prosemirror-view'; +import { PluginKey } from 'prosemirror-state'; + +import { PresentationEditor } from '../PresentationEditor.js'; + +// Create a plugin key for our test highlight plugin +const testHighlightPluginKey = new PluginKey('testHighlight'); + +/** + * Creates a mock decoration object that mimics ProseMirror's Decoration.inline structure. + * The sync method accesses decoration.inline, decoration.from, decoration.to, and decoration.type.attrs + */ +const createMockDecoration = (from: number, to: number, attrs: Record) => ({ + inline: true, + from, + to, + type: { attrs }, +}); + +/** + * Creates a mock DecorationSet that the sync method can iterate over. + * The sync method calls decorationSet.find(0, docSize) and checks instanceof DecorationSet. + */ +const createMockDecorationSet = ( + decorations: Array<{ from: number; to: number; class?: string; attrs?: Record }>, +) => { + const mockDecorations = decorations.map(({ from, to, class: className, attrs }) => + createMockDecoration(from, to, { + ...(className ? { class: className } : {}), + ...attrs, + }), + ); + + // Create an object that passes instanceof DecorationSet check by using the actual prototype + const mockSet = Object.create(DecorationSet.prototype); + mockSet.find = () => mockDecorations; + return mockSet; +}; + +/** + * Creates a mock highlight plugin similar to customer implementations. + * Only mocks spec.key and props.decorations() - the two properties #syncDecorationAttributes + * reads. Real plugins have state.init/apply logic, but the sync method just reads the current + * DecorationSet, so we return a static snapshot. + */ +const createMockHighlightPlugin = ( + decorations: Array<{ from: number; to: number; class?: string; attrs?: Record }>, +) => { + return { + spec: { + key: testHighlightPluginKey, + }, + props: { + decorations: () => createMockDecorationSet(decorations), + }, + }; +}; + +/** + * Creates a mutable mock plugin whose decoration set can be swapped at runtime. + * Simulates the real customer flow: a command dispatches a setMeta transaction, + * the plugin's apply() returns a new DecorationSet, and the bridge picks it up. + */ +const createMutableMockPlugin = () => { + let currentSet: ReturnType = createMockDecorationSet([]); + const plugin = { + spec: { key: testHighlightPluginKey }, + props: { decorations: () => currentSet }, + }; + const setDecorations = ( + items: Array<{ from: number; to: number; class?: string; attrs?: Record }>, + ) => { + currentSet = items.length > 0 ? createMockDecorationSet(items) : DecorationSet.empty; + }; + return { plugin, setDecorations }; +}; + +/** + * PresentationEditor requires extensive mocking due to its many dependencies. + * This follows the established testing pattern used across other PresentationEditor + * test files (e.g., getElementAtPos, zoom, collaboration tests). + */ +const { + createDefaultConverter, + mockClickToPosition, + mockIncrementalLayout, + mockToFlowBlocks, + mockSelectionToRects, + mockCreateDomPainter, + mockEditorConverterStore, + mockEditorOverlayManager, + mockPlugins, + mockEditorOn, +} = vi.hoisted(() => { + const createDefaultConverter = () => ({ + headers: {}, + footers: {}, + headerIds: { + default: null, + first: null, + even: null, + odd: null, + ids: [], + }, + footerIds: { + default: null, + first: null, + even: null, + odd: null, + ids: [], + }, + }); + + const converterStore = { + current: createDefaultConverter() as ReturnType & Record, + mediaFiles: {} as Record, + }; + + // Plugins array that tests can modify + const plugins: Array = []; + + // Shared mock for editor.on() — lets tests extract registered event handlers. + const editorOn = vi.fn(); + + return { + createDefaultConverter, + mockClickToPosition: vi.fn(() => null), + mockIncrementalLayout: vi.fn(async () => ({ layout: { pages: [] }, measures: [] })), + mockToFlowBlocks: vi.fn(() => ({ blocks: [], bookmarks: new Map() })), + mockSelectionToRects: vi.fn(() => []), + mockCreateDomPainter: vi.fn(() => ({ + paint: vi.fn(), + destroy: vi.fn(), + setZoom: vi.fn(), + setLayoutMode: vi.fn(), + setProviders: vi.fn(), + setData: vi.fn(), + })), + mockEditorConverterStore: converterStore, + mockEditorOverlayManager: vi.fn().mockImplementation(() => ({ + showEditingOverlay: vi.fn(() => ({ + success: true, + editorHost: document.createElement('div'), + reason: null, + })), + hideEditingOverlay: vi.fn(), + showSelectionOverlay: vi.fn(), + hideSelectionOverlay: vi.fn(), + setOnDimmingClick: vi.fn(), + getActiveEditorHost: vi.fn(() => null), + destroy: vi.fn(), + })), + mockPlugins: plugins, + mockEditorOn: editorOn, + }; +}); + +vi.mock('../../Editor.js', () => { + return { + Editor: vi.fn().mockImplementation(() => { + const domElement = document.createElement('div'); + + const mockState = { + selection: { from: 0, to: 0 }, + plugins: mockPlugins, + doc: { + nodeSize: 1000, + content: { + size: 998, + }, + descendants: vi.fn(), + nodesBetween: vi.fn(), + resolve: vi.fn((pos: number) => ({ + pos, + depth: 0, + parent: { inlineContent: true }, + })), + }, + tr: { + setSelection: vi.fn().mockReturnThis(), + }, + }; + + return { + setDocumentMode: vi.fn(), + setOptions: vi.fn(), + on: mockEditorOn, + off: vi.fn(), + destroy: vi.fn(), + getJSON: vi.fn(() => ({ type: 'doc', content: [] })), + isEditable: true, + state: mockState, + view: { + dom: domElement, + focus: vi.fn(), + dispatch: vi.fn(), + state: mockState, // Also expose state on view for #syncDecorationAttributes + }, + options: { + documentId: 'test-doc', + element: document.createElement('div'), + }, + converter: mockEditorConverterStore.current, + storage: { + image: { + media: mockEditorConverterStore.mediaFiles, + }, + }, + }; + }), + }; +}); + +vi.mock('@superdoc/pm-adapter', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + toFlowBlocks: mockToFlowBlocks, + }; +}); + +vi.mock('@superdoc/layout-bridge', () => ({ + incrementalLayout: mockIncrementalLayout, + selectionToRects: mockSelectionToRects, + clickToPosition: mockClickToPosition, + createDragHandler: vi.fn(() => () => {}), + getFragmentAtPosition: vi.fn(() => null), + computeLinePmRange: vi.fn(() => ({ from: 0, to: 0 })), + measureCharacterX: vi.fn(() => 0), + extractIdentifierFromConverter: vi.fn(() => ({ + extractHeaderId: vi.fn(() => null), + extractFooterId: vi.fn(() => null), + })), + buildMultiSectionIdentifier: vi.fn(() => ({ sections: [] })), + getHeaderFooterType: vi.fn(() => null), + getHeaderFooterTypeForSection: vi.fn(() => null), + getBucketForPageNumber: vi.fn(() => 0), + getBucketRepresentative: vi.fn(() => 0), + layoutHeaderFooterWithCache: vi.fn(async () => ({})), + computeDisplayPageNumber: vi.fn((pages: Array<{ number?: number }>) => + pages.map((p) => ({ displayText: String(p.number ?? 1) })), + ), + PageGeometryHelper: vi.fn().mockImplementation(() => ({ + updateLayout: vi.fn(), + getPageIndexAtY: vi.fn(() => 0), + getNearestPageIndex: vi.fn(() => 0), + getPageTop: vi.fn(() => 0), + getPageGap: vi.fn(() => 0), + getLayout: vi.fn(() => ({ pages: [] })), + })), +})); + +vi.mock('@superdoc/painter-dom', () => ({ + createDomPainter: mockCreateDomPainter, + DOM_CLASS_NAMES: { + PAGE: 'superdoc-page', + FRAGMENT: 'superdoc-fragment', + LINE: 'superdoc-line', + INLINE_SDT_WRAPPER: 'superdoc-structured-content-inline', + BLOCK_SDT: 'superdoc-structured-content-block', + DOCUMENT_SECTION: 'superdoc-document-section', + }, +})); + +vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ + EditorOverlayManager: mockEditorOverlayManager, +})); + +/** + * Integration tests for decoration bridge sync via PresentationEditor. + * + * These tests verify that DecorationBridge is wired correctly into PresentationEditor's + * lifecycle (observer-triggered rebuild → sync). For unit-level tests of bridge reconciliation + * logic, see DecorationBridge.test.ts. + * + * Coverage: + * - Class syncing: single class, multiple classes, multiple elements, range boundaries + * - Attribute syncing: data-* attributes, combined class + attrs + * - Multiple decorations: non-overlapping ranges, overlapping ranges + * - Edge cases: empty sets, plugins without decorations, empty decorations, attribute filtering + * - Style properties applied at the property level (setProperty/removeProperty) + */ +describe('PresentationEditor.decorationSync', () => { + let container: HTMLElement; + let editor: PresentationEditor; + let painterHost: HTMLElement; + + /** + * Waits for the DomPositionIndexObserverManager to process DOM mutations. + * + * When we append elements to painterHost, the MutationObserver triggers + * scheduleRebuild(), which queues onRebuild() via requestAnimationFrame. + * The observer has built-in debounce protection, so multiple mutations + * only trigger one rebuild. + * + * We use a double RAF here as a waiting mechanism (not a trigger): + * - Frame 1: The observer's scheduled RAF callback runs + * - Frame 2: We know Frame 1 completed, safe to assert + * + * This doesn't cause duplicate onRebuild calls - it's just ensuring + * the prior frame's work finished before we check results. + */ + const waitForSync = () => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); + + /** + * Sets up the editor with the given decorations and returns the painterHost. + * Handles plugin creation, registration, and editor instantiation. + */ + const setupWithDecorations = ( + decorations: Array<{ from: number; to: number; class?: string; attrs?: Record }>, + ) => { + const plugin = createMockHighlightPlugin(decorations); + mockPlugins.push(plugin); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + painterHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + }; + + /** + * Creates a painted span element with PM position attributes and appends it to painterHost. + * Returns the created element for assertions. + */ + const addSpan = (start: number, end: number, text = 'text'): HTMLSpanElement => { + const span = document.createElement('span'); + span.dataset.pmStart = String(start); + span.dataset.pmEnd = String(end); + span.textContent = text; + painterHost.appendChild(span); + return span; + }; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + + vi.clearAllMocks(); + mockEditorConverterStore.current = createDefaultConverter(); + mockEditorConverterStore.mediaFiles = {}; + mockPlugins.length = 0; + }); + + afterEach(() => { + editor?.destroy(); + container?.remove(); + }); + + describe('class decoration syncing', () => { + it('applies decoration classes to painted elements within the decoration range', async () => { + setupWithDecorations([{ from: 5, to: 15, class: 'highlight-selection' }]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.classList.contains('highlight-selection')).toBe(true); + }); + + it('applies decoration classes to multiple elements spanning the range', async () => { + setupWithDecorations([{ from: 5, to: 25, class: 'highlight-selection' }]); + const span1 = addSpan(5, 12); + const span2 = addSpan(12, 20); + const span3 = addSpan(20, 25); + + await waitForSync(); + + expect(span1.classList.contains('highlight-selection')).toBe(true); + expect(span2.classList.contains('highlight-selection')).toBe(true); + expect(span3.classList.contains('highlight-selection')).toBe(true); + }); + + it('applies multiple CSS classes from a single decoration', async () => { + setupWithDecorations([{ from: 5, to: 15, class: 'highlight-selection focus-active custom-style' }]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.classList.contains('highlight-selection')).toBe(true); + expect(span.classList.contains('focus-active')).toBe(true); + expect(span.classList.contains('custom-style')).toBe(true); + }); + + it('does not apply classes to elements outside the decoration range', async () => { + setupWithDecorations([{ from: 10, to: 20, class: 'highlight-selection' }]); + const spanBefore = addSpan(1, 9, 'before'); + const spanWithin = addSpan(10, 20, 'within'); + const spanAfter = addSpan(21, 30, 'after'); + + await waitForSync(); + + expect(spanBefore.classList.contains('highlight-selection')).toBe(false); + expect(spanWithin.classList.contains('highlight-selection')).toBe(true); + expect(spanAfter.classList.contains('highlight-selection')).toBe(false); + }); + }); + + describe('data attribute syncing', () => { + it('applies data-* attributes from decorations to painted elements', async () => { + setupWithDecorations([ + { from: 5, to: 15, attrs: { 'data-highlight-id': 'highlight-123', 'data-clause-type': 'important' } }, + ]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.getAttribute('data-highlight-id')).toBe('highlight-123'); + expect(span.getAttribute('data-clause-type')).toBe('important'); + }); + + it('applies style properties from decoration style attribute', async () => { + setupWithDecorations([{ from: 5, to: 15, attrs: { style: 'background-color: yellow;' } }]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.style.getPropertyValue('background-color')).toBe('yellow'); + }); + + it('applies both classes and data attributes together', async () => { + setupWithDecorations([ + { from: 5, to: 15, class: 'highlight-selection', attrs: { 'data-highlight-id': 'test-456' } }, + ]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.classList.contains('highlight-selection')).toBe(true); + expect(span.getAttribute('data-highlight-id')).toBe('test-456'); + }); + }); + + describe('multiple decorations', () => { + it('handles multiple non-overlapping decorations', async () => { + setupWithDecorations([ + { from: 5, to: 10, class: 'highlight-a' }, + { from: 15, to: 20, class: 'highlight-b' }, + ]); + const span1 = addSpan(5, 10); + const span2 = addSpan(15, 20); + + await waitForSync(); + + expect(span1.classList.contains('highlight-a')).toBe(true); + expect(span1.classList.contains('highlight-b')).toBe(false); + expect(span2.classList.contains('highlight-a')).toBe(false); + expect(span2.classList.contains('highlight-b')).toBe(true); + }); + + it('handles overlapping decorations by applying all classes', async () => { + setupWithDecorations([ + { from: 5, to: 20, class: 'highlight-outer' }, + { from: 10, to: 15, class: 'highlight-inner' }, + ]); + const spanOverlap = addSpan(10, 15, 'overlap'); + const spanOuter = addSpan(5, 10, 'outer only'); + + await waitForSync(); + + expect(spanOverlap.classList.contains('highlight-outer')).toBe(true); + expect(spanOverlap.classList.contains('highlight-inner')).toBe(true); + expect(spanOuter.classList.contains('highlight-outer')).toBe(true); + expect(spanOuter.classList.contains('highlight-inner')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('handles empty decoration set gracefully', async () => { + setupWithDecorations([]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.classList.length).toBe(0); + }); + + it('handles plugins without decorations prop', async () => { + // Manually push a plugin without decorations prop (can't use setupWithDecorations) + mockPlugins.push({ spec: { key: new PluginKey('noDecorations') }, props: {} }); + + editor = new PresentationEditor({ element: container, documentId: 'test-doc' }); + painterHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.classList.length).toBe(0); + }); + + it('handles decorations with no class or attributes gracefully', async () => { + setupWithDecorations([{ from: 5, to: 15 }]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.classList.length).toBe(0); + }); + + it('ignores non-data and non-style attributes', async () => { + setupWithDecorations([ + { from: 5, to: 15, attrs: { 'data-valid': 'yes', id: 'should-be-ignored', onclick: 'alert("xss")' } }, + ]); + const span = addSpan(5, 15); + + await waitForSync(); + + expect(span.getAttribute('data-valid')).toBe('yes'); + expect(span.getAttribute('id')).toBeNull(); + expect(span.getAttribute('onclick')).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Transaction-driven sync (the real customer flow) + // ----------------------------------------------------------------------- + + describe('transaction-driven decoration sync', () => { + /** + * Extracts the 'transaction' event handler that PresentationEditor registered + * on the mock editor. This simulates the real customer flow: a command dispatches + * a setMeta transaction → Editor fires 'transaction' → bridge syncs. + */ + const getTransactionHandler = (): (() => void) => { + const onCalls: Array<[string, () => void]> = mockEditorOn.mock.calls; + const match = onCalls.find(([event]) => event === 'transaction'); + if (!match) throw new Error('No transaction handler registered on mock editor'); + return match[1]; + }; + + it('syncs decorations when a transaction fires (setMeta customer flow)', async () => { + const { plugin, setDecorations } = createMutableMockPlugin(); + mockPlugins.push(plugin); + + editor = new PresentationEditor({ element: container, documentId: 'test-doc' }); + painterHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + const span = addSpan(5, 15); + + // Wait for initial MutationObserver sync (no decorations yet). + await waitForSync(); + expect(span.classList.contains('highlight-selection')).toBe(false); + + // Simulate the customer command: plugin state updates, then transaction fires. + setDecorations([{ from: 5, to: 15, class: 'highlight-selection' }]); + const fireTransaction = getTransactionHandler(); + fireTransaction(); + + await waitForSync(); + expect(span.classList.contains('highlight-selection')).toBe(true); + }); + + it('clears decorations when plugin state is emptied via transaction', async () => { + const { plugin, setDecorations } = createMutableMockPlugin(); + setDecorations([{ from: 5, to: 15, class: 'highlight-selection' }]); + mockPlugins.push(plugin); + + editor = new PresentationEditor({ element: container, documentId: 'test-doc' }); + painterHost = container.querySelector('.presentation-editor__pages') as HTMLElement; + const span = addSpan(5, 15); + + // Wait for initial sync — highlight should be applied. + await waitForSync(); + expect(span.classList.contains('highlight-selection')).toBe(true); + + // Clear decorations and fire transaction (simulates clearHighlight command). + setDecorations([]); + const fireTransaction = getTransactionHandler(); + fireTransaction(); + + await waitForSync(); + expect(span.classList.contains('highlight-selection')).toBe(false); + }); + }); +});