From eba4281057f9d58a32e600cc2e2e670f4581620d Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 2 Mar 2026 20:17:06 +0200 Subject: [PATCH 1/4] fix: prefer full decoration range --- .../dom/DecorationBridge.test.ts | 37 ++++++++++ .../dom/DecorationBridge.ts | 68 ++++++++++++++++--- 2 files changed, 95 insertions(+), 10 deletions(-) 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 index 0c6c1ce076..697622c6c7 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts @@ -873,6 +873,43 @@ describe('DecorationBridge', () => { expect(ranges[0].from).toBe(13); expect(ranges[0].to).toBe(19); }); + + it('prefers full restored span when plugin returns partial (e.g. after applying mark in long selection)', () => { + const fullText = 'Hello world'; + const plugin = mutableExternalPlugin('focus'); + plugin.setDecorations([{ from: 1, to: 12, class: 'highlight-selection' }]); + const state = mockStateWithDocText([plugin.plugin], fullText); + + const ranges1 = bridge.collectDecorationRanges(state); + expect(ranges1).toHaveLength(1); + expect(ranges1[0].to - ranges1[0].from).toBe(fullText.length); + + // Simulate plugin returning only a prefix after mapping (e.g. mark applied in middle) + plugin.setDecorations([{ from: 1, to: 6, class: 'highlight-selection' }]); + const ranges2 = bridge.collectDecorationRanges(state); + expect(ranges2).toHaveLength(1); + // Full span restored by text so highlight does not partially vanish (not just partial 5 chars) + expect(ranges2[0].to - ranges2[0].from).toBeGreaterThan(5); + expect(ranges2[0].classes).toContain('highlight-selection'); + }); + + it('sync applies full span when plugin returns partial so highlight does not vanish', () => { + const { index, addSpan, rebuild } = createIndex(); + addSpan(1, 6, 'Hello'); + const worldSpan = addSpan(6, 12, ' world'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('focus'); + const state = mockStateWithDocText([plugin], 'Hello world'); + setDecorations([{ from: 1, to: 12, class: 'highlight-selection' }]); + bridge.collectDecorationRanges(state); + bridge.sync(state, index); + expect(worldSpan.classList.contains('highlight-selection')).toBe(true); + + setDecorations([{ from: 1, to: 6, class: 'highlight-selection' }]); + bridge.sync(state, index); + expect(worldSpan.classList.contains('highlight-selection')).toBe(true); + }); }); // ----------------------------------------------------------------------- diff --git a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts index b60a559921..bc0b48a52d 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts @@ -215,6 +215,44 @@ function restoreRangesFromPrevious( return out; } +/** Returns the union span of a list of ranges (min from, max to). */ +function rangeUnion(ranges: Array<{ from: number; to: number }>): { from: number; to: number } | null { + if (ranges.length === 0) return null; + let from = ranges[0].from; + let to = ranges[0].to; + for (let i = 1; i < ranges.length; i++) { + if (ranges[i].from < from) from = ranges[i].from; + if (ranges[i].to > to) to = ranges[i].to; + } + return { from, to }; +} + +/** + * When the plugin returns partial ranges (e.g. after applying a mark, mapping can collapse + * decoration ranges), prefer the full span restored by text so the highlight does not + * partially vanish. Returns restored ranges when they form a proper superset of current. + */ +function preferFullRestoredWhenPartial( + current: PreviousRange[], + previousRanges: PreviousRange[] | undefined, + doc: ProseMirrorNode, + docSize: number, +): PreviousRange[] { + if (current.length === 0 || !previousRanges?.length) return current; + const restored = restoreRangesFromPrevious(doc, docSize, previousRanges); + if (restored.length === 0) return current; + + const currentSpan = rangeUnion(current); + const restoredSpan = rangeUnion(restored); + if (!currentSpan || !restoredSpan) return current; + // Prefer restored only when it strictly contains current (plugin returned partial). + const contained = + currentSpan.from >= restoredSpan.from && + currentSpan.to <= restoredSpan.to && + restoredSpan.to - restoredSpan.from > currentSpan.to - currentSpan.from; + return contained ? restored : current; +} + // --------------------------------------------------------------------------- // DecorationBridge // --------------------------------------------------------------------------- @@ -439,13 +477,20 @@ export class DecorationBridge { pluginRanges.push(...restoreRangesFromPrevious(state.doc, docSize, previousPluginRanges)); } + // When plugin returns partial ranges (e.g. after applying a mark, mapping can collapse + // decoration ranges), prefer full span restored by text so highlight does not partially vanish. + const effectiveRanges = + mayRestoreEmpty && previousPluginRanges?.length + ? preferFullRestoredWhenPartial(pluginRanges, previousPluginRanges, state.doc, docSize) + : pluginRanges; + // Store current ranges for next comparison. When we restored from previous, // keep that as the new previous so we don't clear on the next call. - this.#setPreviousRanges(plugin, pluginRanges.length > 0 ? [...pluginRanges] : []); + this.#setPreviousRanges(plugin, effectiveRanges.length > 0 ? [...effectiveRanges] : []); this.#prevDecorationSets.set(plugin, decorationSet); // Add to final output - ranges.push(...pluginRanges); + ranges.push(...effectiveRanges); } this.#clearSkipRestoreFlagIfSet(); @@ -604,13 +649,17 @@ export class DecorationBridge { } } - // Fallback: only when the plugin produced no valid current ranges (e.g. mapping cleared them). - // Restore only when we can relocate by text to a different valid range. - // Never fall back to stale coordinates. - // Do not restore when the plugin has current ranges but they are offscreen (not in domIndex); - // otherwise we would reapply previous ranges to wrong elements and cause highlight drift. - if (pluginHasCurrentRanges) { - this.#setPreviousRanges(plugin, currentRanges); + // When plugin returns partial ranges (e.g. after applying a mark), prefer full span + // restored by text so highlight does not partially vanish. + const previousPluginRanges = this.#previousRanges.get(plugin); + const effectiveRanges = + restoreEmptyDecorations && previousPluginRanges?.length + ? preferFullRestoredWhenPartial(currentRanges, previousPluginRanges, state.doc, docSize) + : currentRanges; + + if (pluginHasCurrentRanges || effectiveRanges.length > 0) { + this.#applyRangesToDesired(desired, domIndex, effectiveRanges); + this.#setPreviousRanges(plugin, effectiveRanges.length > 0 ? [...effectiveRanges] : []); this.#prevDecorationSets.set(plugin, decorationSet); continue; } @@ -619,7 +668,6 @@ export class DecorationBridge { this.#prevDecorationSets.set(plugin, decorationSet); continue; } - const previousPluginRanges = this.#previousRanges.get(plugin); if (previousPluginRanges?.length) { const restoredRanges = restoreRangesFromPrevious(state.doc, docSize, previousPluginRanges); this.#applyRangesToDesired(desired, domIndex, restoredRanges); From d0e13eced51f074a0a380e574fb4d8a36adb51d0 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 2 Mar 2026 21:06:16 +0200 Subject: [PATCH 2/4] fix: consider docChanged flag --- .../dom/DecorationBridge.test.ts | 6 ++++ .../dom/DecorationBridge.ts | 30 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) 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 index 697622c6c7..03e1bd7ba9 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts @@ -884,6 +884,9 @@ describe('DecorationBridge', () => { expect(ranges1).toHaveLength(1); expect(ranges1[0].to - ranges1[0].from).toBe(fullText.length); + // This behavior is only expected right after a doc-changing transaction (e.g. applying a mark) + bridge.recordTransaction({ docChanged: true, mapping: { map: (pos: number) => pos } } as unknown as Transaction); + // Simulate plugin returning only a prefix after mapping (e.g. mark applied in middle) plugin.setDecorations([{ from: 1, to: 6, class: 'highlight-selection' }]); const ranges2 = bridge.collectDecorationRanges(state); @@ -906,6 +909,9 @@ describe('DecorationBridge', () => { bridge.sync(state, index); expect(worldSpan.classList.contains('highlight-selection')).toBe(true); + // Simulate mark application (doc change) that can cause mapping collapse for decorations + bridge.recordTransaction({ docChanged: true, mapping: { map: (pos: number) => pos } } as unknown as Transaction); + setDecorations([{ from: 1, to: 6, class: 'highlight-selection' }]); bridge.sync(state, index); expect(worldSpan.classList.contains('highlight-selection')).toBe(true); diff --git a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts index bc0b48a52d..1c7c02e380 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts @@ -315,6 +315,13 @@ export class DecorationBridge { */ #skipRestoreEmptyOnNextCollect = false; + /** + * Tracks whether the most recently observed transaction was doc-changing. + * Used to distinguish "mapping-induced partial ranges" (doc changes) from + * intentional range changes (meta-only, e.g. setFocus). + */ + #lastTransactionWasDocChange = false; + /** Monotonic token incremented per doc-changing transaction. */ #lastDocChangeToken = 0; @@ -362,6 +369,7 @@ export class DecorationBridge { * decoration sets when plugins don't update their ranges on doc changes. */ recordTransaction(transaction?: Transaction): void { + this.#lastTransactionWasDocChange = Boolean(transaction?.docChanged); if (!transaction?.docChanged) return; this.#lastDocChangeToken += 1; this.#docChangeMappingsByToken.set(this.#lastDocChangeToken, transaction.mapping as unknown as PositionMapping); @@ -480,13 +488,19 @@ export class DecorationBridge { // When plugin returns partial ranges (e.g. after applying a mark, mapping can collapse // decoration ranges), prefer full span restored by text so highlight does not partially vanish. const effectiveRanges = - mayRestoreEmpty && previousPluginRanges?.length + this.#lastTransactionWasDocChange && mayRestoreEmpty && previousPluginRanges?.length ? preferFullRestoredWhenPartial(pluginRanges, previousPluginRanges, state.doc, docSize) : pluginRanges; - // Store current ranges for next comparison. When we restored from previous, - // keep that as the new previous so we don't clear on the next call. - this.#setPreviousRanges(plugin, effectiveRanges.length > 0 ? [...effectiveRanges] : []); + // Store ranges for next comparison. + // - If plugin reported current ranges and we expanded them due to a doc change, store the expanded ones + // so the highlight stays stable across subsequent syncs in the same update cycle. + // - Otherwise, store the plugin-reported current ranges so intentional narrowing is respected. + // - If plugin reported nothing, store the restored (if any). + const storeExpandedOnDocChange = this.#lastTransactionWasDocChange && effectiveRanges !== pluginRanges; + const rangesToStore = + pluginRanges.length > 0 ? (storeExpandedOnDocChange ? effectiveRanges : pluginRanges) : effectiveRanges; + this.#setPreviousRanges(plugin, rangesToStore.length > 0 ? [...rangesToStore] : []); this.#prevDecorationSets.set(plugin, decorationSet); // Add to final output @@ -514,6 +528,7 @@ export class DecorationBridge { this.#previousRangesTokenByPlugin.clear(); this.#hadEligiblePlugins = false; this.#skipRestoreEmptyOnNextCollect = false; + this.#lastTransactionWasDocChange = false; this.#lastDocChangeToken = 0; this.#docChangeMappingsByToken.clear(); // WeakMap entries are garbage collected with their elements. @@ -653,13 +668,16 @@ export class DecorationBridge { // restored by text so highlight does not partially vanish. const previousPluginRanges = this.#previousRanges.get(plugin); const effectiveRanges = - restoreEmptyDecorations && previousPluginRanges?.length + this.#lastTransactionWasDocChange && restoreEmptyDecorations && previousPluginRanges?.length ? preferFullRestoredWhenPartial(currentRanges, previousPluginRanges, state.doc, docSize) : currentRanges; if (pluginHasCurrentRanges || effectiveRanges.length > 0) { this.#applyRangesToDesired(desired, domIndex, effectiveRanges); - this.#setPreviousRanges(plugin, effectiveRanges.length > 0 ? [...effectiveRanges] : []); + const storeExpandedOnDocChange = this.#lastTransactionWasDocChange && effectiveRanges !== currentRanges; + const rangesToStore = + currentRanges.length > 0 ? (storeExpandedOnDocChange ? effectiveRanges : currentRanges) : effectiveRanges; + this.#setPreviousRanges(plugin, rangesToStore.length > 0 ? [...rangesToStore] : []); this.#prevDecorationSets.set(plugin, decorationSet); continue; } From f52aaa321707b4ad1fcb2acccb19048df5f5e595 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 3 Mar 2026 19:01:35 +0200 Subject: [PATCH 3/4] fix: add test/simplify logic with helper --- .../dom/DecorationBridge.test.ts | 45 ++++ .../dom/DecorationBridge.ts | 251 ++++++++---------- 2 files changed, 160 insertions(+), 136 deletions(-) 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 index 03e1bd7ba9..94ba3b2a6e 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.test.ts @@ -916,6 +916,51 @@ describe('DecorationBridge', () => { bridge.sync(state, index); expect(worldSpan.classList.contains('highlight-selection')).toBe(true); }); + + it('returns narrow range as-is when plugin narrows after meta-only transaction (docChanged: false)', () => { + const fullText = 'Hello world'; + const plugin = mutableExternalPlugin('focus'); + plugin.setDecorations([{ from: 1, to: 12, class: 'highlight-selection' }]); + const state = mockStateWithDocText([plugin.plugin], fullText); + + bridge.collectDecorationRanges(state); + expect(bridge.collectDecorationRanges(state)).toHaveLength(1); + + // Meta-only (e.g. setFocus with smaller range): no doc change + bridge.recordTransaction({ docChanged: false, mapping: { map: (pos: number) => pos } } as unknown as Transaction); + plugin.setDecorations([{ from: 1, to: 6, class: 'highlight-selection' }]); + + const ranges = bridge.collectDecorationRanges(state); + expect(ranges).toHaveLength(1); + // Must not expand: narrow range comes back as-is (from 1 to 6 = 5 chars) + expect(ranges[0].to - ranges[0].from).toBe(5); + expect(ranges[0].from).toBe(1); + expect(ranges[0].to).toBe(6); + }); + + it('sync applies narrow range only when plugin narrows after meta-only transaction', () => { + const { index, addSpan, rebuild } = createIndex(); + const helloSpan = addSpan(1, 6, 'Hello'); + const worldSpan = addSpan(6, 12, ' world'); + rebuild(); + + const { plugin, setDecorations } = mutableExternalPlugin('focus'); + const state = mockStateWithDocText([plugin], 'Hello world'); + setDecorations([{ from: 1, to: 12, class: 'highlight-selection' }]); + bridge.collectDecorationRanges(state); + bridge.sync(state, index); + expect(helloSpan.classList.contains('highlight-selection')).toBe(true); + expect(worldSpan.classList.contains('highlight-selection')).toBe(true); + + // Meta-only: user narrowed selection (e.g. setFocus(1, 6)) + bridge.recordTransaction({ docChanged: false, mapping: { map: (pos: number) => pos } } as unknown as Transaction); + setDecorations([{ from: 1, to: 6, class: 'highlight-selection' }]); + bridge.sync(state, index); + + // Narrow range only: first span keeps class, second loses it + expect(helloSpan.classList.contains('highlight-selection')).toBe(true); + expect(worldSpan.classList.contains('highlight-selection')).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 index 1c7c02e380..7f9bb2766a 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts @@ -231,6 +231,15 @@ function rangeUnion(ranges: Array<{ from: number; to: number }>): { from: number * When the plugin returns partial ranges (e.g. after applying a mark, mapping can collapse * decoration ranges), prefer the full span restored by text so the highlight does not * partially vanish. Returns restored ranges when they form a proper superset of current. + * + * Why the range shrinks (mapping, not on purpose): + * - On a doc-changing transaction (e.g. applying bold), the plugin has no meta, so it + * does pluginState.map(tr.mapping, tr.doc). The mapping is produced by the document + * change; calculateInlineRunPropertiesPlugin also appends a transaction that splits + * runs when inline properties differ, so the combined mapping can shift/collapse + * positions. DecorationSet.map() then yields a smaller or split set — a side effect + * of mapping, not an intentional narrowing (which would come from a meta-only + * transaction like setFocus with a smaller range). */ function preferFullRestoredWhenPartial( current: PreviousRange[], @@ -430,80 +439,23 @@ export class DecorationBridge { const docSize = state.doc.content.size; for (const plugin of this.#eligiblePlugins) { - const pluginRanges: PreviousRange[] = []; - const decorationSet = this.#getDecorationSet(plugin, state); - const prevDecorationSet = this.#prevDecorationSets.get(plugin); - const remapped = this.#remapUnchangedPluginRangesIfNeeded( - plugin, - decorationSet, - prevDecorationSet, - state.doc, - docSize, - ); - if (remapped) { - pluginRanges.push(...remapped); - } else if (decorationSet !== DecorationSet.empty) { - const decorations = decorationSet.find(0, docSize); - for (const decoration of decorations) { - if (!this.#isInlineDecoration(decoration)) continue; - - const attrs = this.#extractSafeAttrs(decoration); - // Only include decorations that have visual styling (classes or inline style) - if (attrs.classes.length === 0 && attrs.styleEntries.length === 0) continue; - // Collapsed or invalid range must not enter the cache or restore breaks - if (decoration.from >= decoration.to) continue; - - const dataAttrs: Record = {}; - for (const [key, value] of attrs.dataEntries) dataAttrs[key] = value; - - const rangeText = - typeof state.doc.textBetween === 'function' - ? state.doc.textBetween(decoration.from, decoration.to, TEXT_RANGE_BLOCK_SEP, TEXT_RANGE_LEAF_SEP) - : undefined; - pluginRanges.push({ - from: decoration.from, - to: decoration.to, - classes: attrs.classes, - style: - attrs.styleEntries.length > 0 - ? attrs.styleEntries.map(([prop, val]) => `${prop}: ${val}`).join('; ') - : null, - dataAttrs, - ...(rangeText ? { text: rangeText } : {}), - }); - } - } + const { ranges: pluginRanges, decorationSet } = this.#collectPluginRanges(plugin, state, docSize); - // Fallback: If plugin has no ranges but previously had valid ranges, - // restore only when we can relocate by text to a different valid range. - // Never fall back to stale coordinates. - // Skip restore when sync() was called with restoreEmptyDecorations: false (e.g. clearFocus). const previousPluginRanges = this.#previousRanges.get(plugin); const mayRestoreEmpty = !this.#skipRestoreEmptyOnNextCollect && previousPluginRanges && previousPluginRanges.length > 0; - if (pluginRanges.length === 0 && mayRestoreEmpty) { - pluginRanges.push(...restoreRangesFromPrevious(state.doc, docSize, previousPluginRanges)); - } - // When plugin returns partial ranges (e.g. after applying a mark, mapping can collapse - // decoration ranges), prefer full span restored by text so highlight does not partially vanish. - const effectiveRanges = - this.#lastTransactionWasDocChange && mayRestoreEmpty && previousPluginRanges?.length - ? preferFullRestoredWhenPartial(pluginRanges, previousPluginRanges, state.doc, docSize) - : pluginRanges; - - // Store ranges for next comparison. - // - If plugin reported current ranges and we expanded them due to a doc change, store the expanded ones - // so the highlight stays stable across subsequent syncs in the same update cycle. - // - Otherwise, store the plugin-reported current ranges so intentional narrowing is respected. - // - If plugin reported nothing, store the restored (if any). - const storeExpandedOnDocChange = this.#lastTransactionWasDocChange && effectiveRanges !== pluginRanges; - const rangesToStore = - pluginRanges.length > 0 ? (storeExpandedOnDocChange ? effectiveRanges : pluginRanges) : effectiveRanges; + const { effectiveRanges, rangesToStore } = this.#resolveEffectiveRanges( + pluginRanges, + previousPluginRanges, + state.doc, + docSize, + mayRestoreEmpty, + this.#lastTransactionWasDocChange, + ); + this.#setPreviousRanges(plugin, rangesToStore.length > 0 ? [...rangesToStore] : []); this.#prevDecorationSets.set(plugin, decorationSet); - - // Add to final output ranges.push(...effectiveRanges); } @@ -604,79 +556,20 @@ export class DecorationBridge { const desired = new Map(); for (const plugin of this.#eligiblePlugins) { - const decorationSet = this.#getDecorationSet(plugin, state); - const prevDecorationSet = this.#prevDecorationSets.get(plugin); - const remapped = this.#remapUnchangedPluginRangesIfNeeded( - plugin, - decorationSet, - prevDecorationSet, + const { ranges: pluginRanges, decorationSet } = this.#collectPluginRanges(plugin, state, docSize); + + const previousPluginRanges = this.#previousRanges.get(plugin); + const { effectiveRanges, rangesToStore } = this.#resolveEffectiveRanges( + pluginRanges, + previousPluginRanges, state.doc, docSize, + restoreEmptyDecorations, + this.#lastTransactionWasDocChange, ); - if (remapped) { - this.#applyRangesToDesired(desired, domIndex, remapped); - this.#setPreviousRanges(plugin, [...remapped]); - this.#prevDecorationSets.set(plugin, decorationSet); - continue; - } - - let pluginHasCurrentRanges = false; - const currentRanges: PreviousRange[] = []; - if (decorationSet !== DecorationSet.empty) { - 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; - - // Collapsed or invalid range yields no entries; mapping can produce from === to - if (decoration.from >= decoration.to) continue; - - pluginHasCurrentRanges = true; - - const entries = domIndex.findEntriesInRange(decoration.from, decoration.to); - for (const entry of entries) { - const d = this.#getOrCreateDesired(desired, entry.el); - for (const cls of attrs.classes) d.classes.add(cls); - for (const [key, value] of attrs.dataEntries) d.dataAttrs.set(key, value); - for (const [prop, value] of attrs.styleEntries) d.styleProps.set(prop, value); - } - - const dataAttrs: Record = {}; - for (const [key, value] of attrs.dataEntries) dataAttrs[key] = value; - const style = - attrs.styleEntries.length > 0 - ? attrs.styleEntries.map(([prop, val]) => `${prop}: ${val}`).join('; ') - : null; - const rangeText = - typeof state.doc.textBetween === 'function' - ? state.doc.textBetween(decoration.from, decoration.to, TEXT_RANGE_BLOCK_SEP, TEXT_RANGE_LEAF_SEP) - : undefined; - currentRanges.push({ - from: decoration.from, - to: decoration.to, - classes: attrs.classes, - style, - dataAttrs, - ...(rangeText ? { text: rangeText } : {}), - }); - } - } - - // When plugin returns partial ranges (e.g. after applying a mark), prefer full span - // restored by text so highlight does not partially vanish. - const previousPluginRanges = this.#previousRanges.get(plugin); - const effectiveRanges = - this.#lastTransactionWasDocChange && restoreEmptyDecorations && previousPluginRanges?.length - ? preferFullRestoredWhenPartial(currentRanges, previousPluginRanges, state.doc, docSize) - : currentRanges; - if (pluginHasCurrentRanges || effectiveRanges.length > 0) { + if (pluginRanges.length > 0 || effectiveRanges.length > 0) { this.#applyRangesToDesired(desired, domIndex, effectiveRanges); - const storeExpandedOnDocChange = this.#lastTransactionWasDocChange && effectiveRanges !== currentRanges; - const rangesToStore = - currentRanges.length > 0 ? (storeExpandedOnDocChange ? effectiveRanges : currentRanges) : effectiveRanges; this.#setPreviousRanges(plugin, rangesToStore.length > 0 ? [...rangesToStore] : []); this.#prevDecorationSets.set(plugin, decorationSet); continue; @@ -717,6 +610,91 @@ export class DecorationBridge { } } + /** + * Collects current decoration ranges for one plugin: either remapped from previous + * (when DecorationSet reference unchanged) or decoded from the plugin's DecorationSet. + * Shared by collectDecorationRanges and #collectDesiredState. + */ + #collectPluginRanges( + plugin: Plugin, + state: EditorState, + docSize: number, + ): { ranges: PreviousRange[]; decorationSet: DecorationSet } { + const decorationSet = this.#getDecorationSet(plugin, state); + const prevDecorationSet = this.#prevDecorationSets.get(plugin); + const remapped = this.#remapUnchangedPluginRangesIfNeeded( + plugin, + decorationSet, + prevDecorationSet, + state.doc, + docSize, + ); + if (remapped) { + return { ranges: remapped, decorationSet }; + } + + const ranges: PreviousRange[] = []; + if (decorationSet !== DecorationSet.empty) { + 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; + if (decoration.from >= decoration.to) continue; + + const dataAttrs: Record = {}; + for (const [key, value] of attrs.dataEntries) dataAttrs[key] = value; + + const style = + attrs.styleEntries.length > 0 ? attrs.styleEntries.map(([prop, val]) => `${prop}: ${val}`).join('; ') : null; + const rangeText = + typeof state.doc.textBetween === 'function' + ? state.doc.textBetween(decoration.from, decoration.to, TEXT_RANGE_BLOCK_SEP, TEXT_RANGE_LEAF_SEP) + : undefined; + + ranges.push({ + from: decoration.from, + to: decoration.to, + classes: attrs.classes, + style, + dataAttrs, + ...(rangeText ? { text: rangeText } : {}), + }); + } + } + return { ranges, decorationSet }; + } + + /** + * Resolves effective ranges (restore empty + prefer full when partial) and what to store + * as previous. Shared by collectDecorationRanges and #collectDesiredState. + */ + #resolveEffectiveRanges( + pluginRanges: PreviousRange[], + previousPluginRanges: PreviousRange[] | undefined, + doc: ProseMirrorNode, + docSize: number, + restoreEmpty: boolean, + lastTransactionWasDocChange: boolean, + ): { effectiveRanges: PreviousRange[]; rangesToStore: PreviousRange[] } { + let current = pluginRanges; + if (current.length === 0 && restoreEmpty && previousPluginRanges?.length) { + current = restoreRangesFromPrevious(doc, docSize, previousPluginRanges); + } + + const effectiveRanges = + lastTransactionWasDocChange && restoreEmpty && previousPluginRanges?.length + ? preferFullRestoredWhenPartial(current, previousPluginRanges, doc, docSize) + : current; + + const storeExpandedOnDocChange = lastTransactionWasDocChange && effectiveRanges !== current; + const rangesToStore = + pluginRanges.length > 0 ? (storeExpandedOnDocChange ? effectiveRanges : pluginRanges) : effectiveRanges; + + return { effectiveRanges, rangesToStore }; + } + /** Stores previous ranges and tags them with the current doc-change token. */ #setPreviousRanges(plugin: Plugin, ranges: PreviousRange[]): void { this.#previousRanges.set(plugin, ranges); @@ -768,6 +746,8 @@ export class DecorationBridge { * When a plugin returns the exact same DecorationSet reference after a doc change, * remap cached previous ranges with transaction mapping. This supports external * plugins that return static DecorationSet instances instead of mapping ranges. + * Callers are responsible for setting #previousRanges (via #resolveEffectiveRanges + * and #setPreviousRanges); this method only returns the remapped ranges. */ #remapUnchangedPluginRangesIfNeeded( plugin: Plugin, @@ -821,7 +801,6 @@ export class DecorationBridge { } if (remapped.length === 0) return null; - this.#setPreviousRanges(plugin, remapped); return remapped; } From d940fcaa4f0c25a1b5f05829bac4df3c097864ec Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 3 Mar 2026 14:34:35 -0300 Subject: [PATCH 4/4] fix: remove dead restore fallback in #collectDesiredState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #resolveEffectiveRanges already calls restoreRangesFromPrevious with the same arguments — the second call was guaranteed to return the same empty result. Also adds the missing #setPreviousRanges call to clear stale ranges on this path. --- .../src/core/presentation-editor/dom/DecorationBridge.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts index 7f9bb2766a..3a35dfe071 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts @@ -579,10 +579,7 @@ export class DecorationBridge { this.#prevDecorationSets.set(plugin, decorationSet); continue; } - if (previousPluginRanges?.length) { - const restoredRanges = restoreRangesFromPrevious(state.doc, docSize, previousPluginRanges); - this.#applyRangesToDesired(desired, domIndex, restoredRanges); - } + this.#setPreviousRanges(plugin, []); this.#prevDecorationSets.set(plugin, decorationSet); }