Skip to content

Commit 08ffc41

Browse files
VladaHarbourmattConnHarbourharbournickcaio-pizzol
authored
Decoration range incorrectly expands to run boundaries; highlight can be lost after mark changes (#2173)
* fix: separate logic for decoration sync in presentation editor * fix: move from overlay approach to paragraph splitting * fix: clear selection * fix: prevent highlighted decoration position shift * fix: remove console.log * fix: decoration restore condition * fix(super-editor): compose decoration remap so external highlights track replacement and mark split * fix: logic improvements * fix: create shared helper for repeated code * fix: update tests and address comments * test: add behavior tests for decoration survival after mark changes Playwright tests covering SD-1963: comment highlights and track-change decorations must persist when the user applies bold/italic/underline to overlapping or partially-overlapping text. * fix(test): check comment highlight by id after run split When italic is applied to part of a commented range, the run splits and the highlight spans multiple DOM elements. Assert by commentId and individual text fragments instead of the full contiguous text. --------- Co-authored-by: Matthew Connelly <matthew@harbourshare.com> Co-authored-by: Nick Bernal <nick@superdoc.dev> Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 2452c37 commit 08ffc41

10 files changed

Lines changed: 1502 additions & 27 deletions

File tree

packages/layout-engine/layout-bridge/src/dom-reconciler.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,6 @@ export class DomReconciler {
383383

384384
// Update position/size attributes if needed
385385
// (In a real implementation, this would update transform/position styles)
386-
387386
return updated;
388387
}
389388

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { ySyncPluginKey } from 'y-prosemirror';
110110
import type * as Y from 'yjs';
111111
import type { HeaderFooterDescriptor } from '../header-footer/HeaderFooterRegistry.js';
112112
import { isInRegisteredSurface } from './utils/uiSurfaceRegistry.js';
113+
import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js';
113114

114115
// Types
115116
import type {
@@ -2341,8 +2342,10 @@ export class PresentationEditor extends EventEmitter {
23412342
}
23422343

23432344
/**
2344-
* Runs a full decoration bridge sync: reads external plugin decorations and
2345-
* reconciles them onto painted DOM elements (add/update/remove).
2345+
* Runs a full decoration sync: applies external plugin decoration classes
2346+
* and styles to the painted DOM elements via DecorationBridge. Runs are
2347+
* split at decoration boundaries during layout so only the selected portion
2348+
* gets the background (like the highlight mark, without applying a mark).
23462349
*
23472350
* Called synchronously from post-paint and observer-rebuild paths where the
23482351
* DOM index is guaranteed to be fresh.
@@ -2354,7 +2357,9 @@ export class PresentationEditor extends EventEmitter {
23542357
try {
23552358
this.#decorationBridge.sync(state, this.#domPositionIndex);
23562359
} catch (error) {
2357-
debugLog('warn', 'Decoration bridge sync failed', { error: String(error) });
2360+
// Sync can call findRangeByText and other doc-dependent logic; if it throws
2361+
// (e.g. edge-case doc state), avoid breaking the RAF or observer sync loop.
2362+
console.warn('[PresentationEditor] Decoration sync failed:', error);
23582363
}
23592364
}
23602365

@@ -2463,8 +2468,30 @@ export class PresentationEditor extends EventEmitter {
24632468
// We listen on 'transaction' so the decoration bridge picks up changes
24642469
// from any transaction type. The bridge's own identity check + RAF
24652470
// coalescing prevent unnecessary work.
2466-
const handleTransaction = () => {
2467-
this.#scheduleDecorationSync();
2471+
// When decoration state changes without a doc change (e.g. setFocus), we must
2472+
// still run a full rerender so runs are split at the new decoration boundaries;
2473+
// otherwise the bridge applies the class to whole runs and highlights too much.
2474+
const handleTransaction = (event?: { transaction?: Transaction }) => {
2475+
const tr = event?.transaction;
2476+
this.#decorationBridge.recordTransaction(tr);
2477+
const state = this.#editor?.view?.state;
2478+
const decorationChanged = state && this.#decorationBridge.hasChanges(state);
2479+
// Sync immediately whenever decorations changed so e.g. clearFocus removes
2480+
// highlight-selection in the same tick. Only restore when we had a doc change.
2481+
if (decorationChanged) {
2482+
const restoreEmpty = tr ? tr.docChanged === true : false;
2483+
this.#decorationBridge.sync(state!, this.#domPositionIndex, {
2484+
restoreEmptyDecorations: restoreEmpty,
2485+
});
2486+
} else {
2487+
// No immediate sync; schedule coalesced sync on next frame.
2488+
this.#scheduleDecorationSync();
2489+
}
2490+
if (decorationChanged) {
2491+
this.#pendingDocChange = true;
2492+
this.#selectionSync.onLayoutStart();
2493+
this.#scheduleRerender();
2494+
}
24682495
};
24692496

24702497
this.#editor.on('update', handleUpdate);
@@ -3160,6 +3187,17 @@ export class PresentationEditor extends EventEmitter {
31603187
return;
31613188
}
31623189

3190+
// Split runs at decoration boundaries so bridge sync applies background only to the
3191+
// selected portion (like highlight mark) without adding a document mark.
3192+
const state = this.#editor?.view?.state;
3193+
const decorationRanges = state ? this.#decorationBridge.collectDecorationRanges(state) : [];
3194+
if (decorationRanges.length > 0) {
3195+
blocks = splitRunsAtDecorationBoundaries(
3196+
blocks,
3197+
decorationRanges.map((r) => ({ from: r.from, to: r.to })),
3198+
);
3199+
}
3200+
31633201
this.#applyHtmlAnnotationMeasurements(blocks);
31643202

31653203
const baseLayoutOptions = this.#resolveLayoutOptions(blocks, sectionMetadata);

0 commit comments

Comments
 (0)