Skip to content

Commit 8e2fcf1

Browse files
authored
feat: unify edit history (#2946)
1 parent 6b41a26 commit 8e2fcf1

40 files changed

Lines changed: 4678 additions & 331 deletions

packages/document-api/src/contract/operation-definitions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3317,7 +3317,7 @@ export const OPERATION_DEFINITIONS = {
33173317

33183318
'history.get': {
33193319
memberPath: 'history.get',
3320-
description: 'Query the current undo/redo history state of the active editor.',
3320+
description: 'Query the current undo/redo history state of the document.',
33213321
expectedResult:
33223322
'Returns a HistoryState object with undoDepth, redoDepth, canUndo, canRedo, and a list of history-unsafe operations.',
33233323
requiresDocumentContext: true,
@@ -3330,7 +3330,7 @@ export const OPERATION_DEFINITIONS = {
33303330

33313331
'history.undo': {
33323332
memberPath: 'history.undo',
3333-
description: 'Undo the most recent history-safe mutation in the active editor.',
3333+
description: 'Undo the most recent history-safe mutation in the document.',
33343334
expectedResult:
33353335
'Returns a HistoryActionResult with noop flag, reason (EMPTY_UNDO_STACK | NO_EFFECT when noop), and revision before/after.',
33363336
requiresDocumentContext: true,
@@ -3350,7 +3350,7 @@ export const OPERATION_DEFINITIONS = {
33503350

33513351
'history.redo': {
33523352
memberPath: 'history.redo',
3353-
description: 'Redo the most recently undone action in the active editor.',
3353+
description: 'Redo the most recently undone action in the document.',
33543354
expectedResult:
33553355
'Returns a HistoryActionResult with noop flag, reason (EMPTY_REDO_STACK | NO_EFFECT when noop), and revision before/after.',
33563356
requiresDocumentContext: true,

packages/layout-engine/layout-bridge/src/text-measurement.ts

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ let measurementCanvas: HTMLCanvasElement | null = null;
1919
let measurementCtx: CanvasRenderingContext2D | null = null;
2020

2121
const TAB_CHAR_LENGTH = 1;
22+
const FOOTNOTE_MARKER_DATA_ATTR = 'data-sd-footnote-number';
23+
24+
const getRunDataAttrs = (run: Run | undefined): Record<string, string> | undefined => {
25+
if (!run || !('dataAttrs' in run)) {
26+
return undefined;
27+
}
28+
return run.dataAttrs;
29+
};
2230

2331
const getRunCharacterLength = (run: Run | undefined): number => {
2432
if (!run) return 0;
@@ -35,6 +43,10 @@ const getRunCharacterLength = (run: Run | undefined): number => {
3543
return run.text?.length ?? 0;
3644
};
3745

46+
const isVisualOnlyRun = (run: Run | undefined): boolean => {
47+
return getRunDataAttrs(run)?.[FOOTNOTE_MARKER_DATA_ATTR] === 'true';
48+
};
49+
3850
/**
3951
* Characters considered as spaces for justify alignment calculations.
4052
* Only includes regular space (U+0020) and non-breaking space (U+00A0).
@@ -703,28 +715,27 @@ export function charOffsetToPm(block: FlowBlock, line: Line, charOffset: number,
703715
let cursor = 0;
704716
let lastPm = fallbackPmStart;
705717

706-
for (const run of runs) {
707-
const isTab = isTabRun(run);
708-
const text =
709-
'src' in run ||
710-
run.kind === 'lineBreak' ||
711-
run.kind === 'break' ||
712-
run.kind === 'fieldAnnotation' ||
713-
run.kind === 'math'
714-
? ''
715-
: (run.text ?? '');
716-
const runLength = isTab ? TAB_CHAR_LENGTH : text.length;
717-
718-
const runPmStart = typeof run.pmStart === 'number' ? run.pmStart : null;
719-
const runPmEnd = typeof run.pmEnd === 'number' ? run.pmEnd : runPmStart != null ? runPmStart + runLength : null;
718+
for (let runIndex = 0; runIndex < runs.length; runIndex += 1) {
719+
const run = runs[runIndex];
720+
const runLength = getRunCharacterLength(run);
721+
const runPmStart = resolveRunPmStart(run, runLength);
722+
const runPmEnd = resolveRunPmEnd(run, runLength, runPmStart);
720723

721724
if (runPmStart != null) {
722725
lastPm = runPmStart;
723726
}
724727

725728
if (safeCharOffset <= cursor + runLength) {
726729
const offsetInRun = Math.max(0, safeCharOffset - cursor);
727-
return runPmStart != null ? runPmStart + Math.min(offsetInRun, runLength) : fallbackPmStart + safeCharOffset;
730+
if (runPmStart != null) {
731+
return runPmStart + Math.min(offsetInRun, runLength);
732+
}
733+
734+
if (isVisualOnlyRun(run)) {
735+
return resolveVisualOnlyRunBoundary(runs, runIndex, offsetInRun, runLength, lastPm);
736+
}
737+
738+
return fallbackPmStart + safeCharOffset;
728739
}
729740

730741
if (runPmEnd != null) {
@@ -737,6 +748,72 @@ export function charOffsetToPm(block: FlowBlock, line: Line, charOffset: number,
737748
return lastPm;
738749
}
739750

751+
const resolveRunPmStart = (run: Run | undefined, runLength: number): number | null => {
752+
if (!run) {
753+
return null;
754+
}
755+
756+
if (typeof run.pmStart === 'number') {
757+
return run.pmStart;
758+
}
759+
760+
if (typeof run.pmEnd === 'number') {
761+
return run.pmEnd - runLength;
762+
}
763+
764+
return null;
765+
};
766+
767+
const resolveRunPmEnd = (run: Run | undefined, runLength: number, runPmStart: number | null): number | null => {
768+
if (!run) {
769+
return null;
770+
}
771+
772+
if (typeof run.pmEnd === 'number') {
773+
return run.pmEnd;
774+
}
775+
776+
if (runPmStart != null) {
777+
return runPmStart + runLength;
778+
}
779+
780+
return null;
781+
};
782+
783+
const findNextPmBoundary = (runs: readonly Run[], startIndex: number, fallbackPm: number): number => {
784+
for (let runIndex = startIndex; runIndex < runs.length; runIndex += 1) {
785+
const run = runs[runIndex];
786+
const runLength = getRunCharacterLength(run);
787+
const nextPmStart = resolveRunPmStart(run, runLength);
788+
if (nextPmStart != null) {
789+
return nextPmStart;
790+
}
791+
792+
const nextPmEnd = resolveRunPmEnd(run, runLength, nextPmStart);
793+
if (nextPmEnd != null) {
794+
return nextPmEnd;
795+
}
796+
}
797+
798+
return fallbackPm;
799+
};
800+
801+
const resolveVisualOnlyRunBoundary = (
802+
runs: readonly Run[],
803+
runIndex: number,
804+
offsetInRun: number,
805+
runLength: number,
806+
previousPmBoundary: number,
807+
): number => {
808+
const nextPmBoundary = findNextPmBoundary(runs, runIndex + 1, previousPmBoundary);
809+
if (runLength <= 0 || previousPmBoundary === nextPmBoundary) {
810+
return previousPmBoundary;
811+
}
812+
813+
const midpoint = runLength / 2;
814+
return offsetInRun < midpoint ? previousPmBoundary : nextPmBoundary;
815+
};
816+
740817
/**
741818
* Find the character offset and PM position at a given X coordinate within a line.
742819
* This is the inverse of measureCharacterX.

packages/layout-engine/layout-bridge/test/text-measurement.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,25 @@ describe('text measurement utility', () => {
238238
expect(secondTab.pmPosition).toBeLessThanOrEqual(3);
239239
});
240240

241+
it('maps clicks through leading visual-only marker runs to editable PM positions', () => {
242+
const block = createBlock([
243+
{ text: '1', fontFamily: 'Arial', fontSize: 16, dataAttrs: { 'data-sd-footnote-number': 'true' } } as Run,
244+
{ text: 'Hello', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 },
245+
]);
246+
const line = baseLine({
247+
fromRun: 0,
248+
toRun: 1,
249+
toChar: 6,
250+
width: 6 * CHAR_WIDTH,
251+
});
252+
253+
const markerHit = findCharacterAtX(block, line, CHAR_WIDTH / 2, 0);
254+
expect(markerHit.pmPosition).toBe(0);
255+
256+
const textHit = findCharacterAtX(block, line, CHAR_WIDTH * 3.5, 0);
257+
expect(textHit.pmPosition).toBe(3);
258+
});
259+
241260
describe('charOffsetToPm edge cases', () => {
242261
it('clamps character offset beyond line bounds to end position', () => {
243262
const block = createBlock([{ text: 'Hello', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }]);
@@ -380,6 +399,22 @@ describe('text measurement utility', () => {
380399
const result = charOffsetToPm(block, line, 0, 5);
381400
expect(result).toBe(5);
382401
});
402+
403+
it('does not advance PM positions through visual-only marker runs', () => {
404+
const block = createBlock([
405+
{ text: '1', fontFamily: 'Arial', fontSize: 16, dataAttrs: { 'data-sd-footnote-number': 'true' } } as Run,
406+
{ text: 'Hello', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 },
407+
]);
408+
const line = baseLine({
409+
fromRun: 0,
410+
toRun: 1,
411+
toChar: 6,
412+
});
413+
414+
expect(charOffsetToPm(block, line, 0, 0)).toBe(0);
415+
expect(charOffsetToPm(block, line, 1, 0)).toBe(0);
416+
expect(charOffsetToPm(block, line, 3, 0)).toBe(2);
417+
});
383418
});
384419

385420
describe('countSpaces helper', () => {

packages/super-editor/src/editors/v1/core/extensions/editable.js

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,86 @@ import { Plugin, PluginKey } from 'prosemirror-state';
22
import { __endComposition } from 'prosemirror-view';
33
import { Extension } from '../Extension.js';
44

5-
const handleInsertTextBeforeInput = (view, event) => {
5+
const appendStoryInputDebugLog = (entry) => {
6+
const debugGlobal = globalThis;
7+
if (debugGlobal.__SD_DEBUG_STORY_INPUT__ !== true) {
8+
return;
9+
}
10+
11+
const existingLog = Array.isArray(debugGlobal.__SD_DEBUG_STORY_INPUT_LOG__)
12+
? debugGlobal.__SD_DEBUG_STORY_INPUT_LOG__
13+
: [];
14+
15+
existingLog.push(entry);
16+
if (existingLog.length > 200) {
17+
existingLog.splice(0, existingLog.length - 200);
18+
}
19+
20+
debugGlobal.__SD_DEBUG_STORY_INPUT_LOG__ = existingLog;
21+
};
22+
23+
const isStorySurfaceEditor = (editor) => {
24+
const documentId = editor?.options?.documentId ?? '';
25+
return documentId.startsWith('hf:') || documentId.startsWith('fn:') || documentId.startsWith('en:');
26+
};
27+
28+
const recordStoryInputDebug = (view, event, editor, phase, extra = {}) => {
29+
if (!isStorySurfaceEditor(editor)) {
30+
return;
31+
}
32+
33+
let domAnchorPos = null;
34+
const domSelection = view?.dom?.ownerDocument?.getSelection?.() ?? null;
35+
36+
try {
37+
if (view?.dom && domSelection?.anchorNode && view.dom.contains(domSelection.anchorNode)) {
38+
domAnchorPos = view.posAtDOM(domSelection.anchorNode, domSelection.anchorOffset, -1);
39+
}
40+
} catch {
41+
domAnchorPos = null;
42+
}
43+
44+
appendStoryInputDebugLog({
45+
phase,
46+
documentId: editor?.options?.documentId ?? null,
47+
inputType: event?.inputType ?? null,
48+
data: event?.data ?? null,
49+
cancelable: event?.cancelable ?? null,
50+
defaultPrevented: event?.defaultPrevented ?? null,
51+
selectionFrom: view?.state?.selection?.from ?? null,
52+
selectionTo: view?.state?.selection?.to ?? null,
53+
domAnchorPos,
54+
...extra,
55+
});
56+
};
57+
58+
const handleInsertTextBeforeInput = (view, event, editor) => {
659
const isInsertTextInput = event?.inputType === 'insertText';
760
const hasTextData = typeof event?.data === 'string' && event.data.length > 0;
861
const isComposing = event?.isComposing === true;
962

63+
recordStoryInputDebug(view, event, editor, 'beforeinput:start', {
64+
isInsertTextInput,
65+
hasTextData,
66+
isComposing,
67+
});
68+
1069
if (!isInsertTextInput || !hasTextData || isComposing) {
70+
recordStoryInputDebug(view, event, editor, 'beforeinput:skip');
1171
return false;
1272
}
1373

1474
const selection = view.state.selection;
15-
if (selection.empty) {
75+
if (selection.empty && !isStorySurfaceEditor(editor)) {
76+
recordStoryInputDebug(view, event, editor, 'beforeinput:skip-empty-selection');
1677
return false;
1778
}
1879

1980
const tr = view.state.tr.insertText(event.data, selection.from, selection.to);
2081
tr.setMeta('inputType', 'insertText');
2182
view.dispatch(tr);
2283
event.preventDefault();
84+
recordStoryInputDebug(view, event, editor, 'beforeinput:handled');
2385

2486
return true;
2587
};
@@ -91,6 +153,7 @@ export const Editable = Extension.create({
91153
editable: () => editor.options.editable,
92154
handleDOMEvents: {
93155
beforeinput: (view, event) => {
156+
recordStoryInputDebug(view, event, editor, 'dom:beforeinput');
94157
if (!editor.options.editable) {
95158
event.preventDefault();
96159
return true;
@@ -104,11 +167,15 @@ export const Editable = Extension.create({
104167
// can widen the replace range around hidden inline content in story
105168
// editors. Apply the replacement against the PM selection directly
106169
// before the browser mutates the DOM.
107-
if (handleInsertTextBeforeInput(view, event)) {
170+
if (handleInsertTextBeforeInput(view, event, editor)) {
108171
return true;
109172
}
110173
return false;
111174
},
175+
input: (view, event) => {
176+
recordStoryInputDebug(view, event, editor, 'dom:input');
177+
return false;
178+
},
112179
compositionstart: (view, event) => blockWhenNotEditable(view, event),
113180
compositionupdate: (view, event) => blockWhenNotEditable(view, event),
114181
compositionend: (view, event) => blockWhenNotEditable(view, event),

0 commit comments

Comments
 (0)