Skip to content

Commit de17637

Browse files
committed
fix: preserve undo through clean projections
1 parent aefcfed commit de17637

5 files changed

Lines changed: 187 additions & 36 deletions

File tree

src/features/editor/plugins/inlineSourceProjection.test.tsx

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,28 +49,38 @@ const enterProjection = (mounted: MountedMilkdownEditor, selector: "em" | "stron
4949
};
5050

5151
const getTextPosition = (mounted: MountedMilkdownEditor, text: string) => {
52-
let position: number | null = null;
52+
const textRanges: { end: number; from: number; start: number }[] = [];
53+
let documentText = "";
5354

5455
mounted.view.state.doc.descendants((node, pos) => {
5556
if (!node.isText) {
5657
return true;
5758
}
5859

59-
const index = node.textContent.indexOf(text);
60-
61-
if (index === -1) {
62-
return true;
63-
}
60+
const start = documentText.length;
61+
documentText += node.textContent;
62+
textRanges.push({
63+
end: documentText.length,
64+
from: pos,
65+
start,
66+
});
6467

65-
position = pos + index;
66-
return false;
68+
return true;
6769
});
6870

69-
if (position === null) {
71+
const index = documentText.indexOf(text);
72+
73+
if (index === -1) {
7074
throw new Error(`Could not find projected text: ${text}`);
7175
}
7276

73-
return position;
77+
const range = textRanges.find(({ end, start }) => start <= index && index < end);
78+
79+
if (!range) {
80+
throw new Error(`Could not resolve projected text position: ${text}`);
81+
}
82+
83+
return range.from + index - range.start;
7484
};
7585

7686
const runCommand = async (mounted: MountedMilkdownEditor, commandId: "edit.redo" | "edit.undo") =>
@@ -328,6 +338,57 @@ describe("inline source projection", () => {
328338
expect(mounted.getMarkdown()).toBe("*One* **Two**\n");
329339
});
330340

341+
it("preserves native undo after committing a marker deletion", async () => {
342+
const mounted = await mountEditor("**Bold** plain");
343+
344+
enterProjection(mounted, "strong");
345+
346+
const sourceStart = getTextPosition(mounted, "**Bold**");
347+
348+
setTextSelection(mounted.view, sourceStart + 1);
349+
pressKey(mounted.view, "Backspace");
350+
setSelectionAtDocumentEnd(mounted.view);
351+
352+
expect(mounted.getMarkdown()).toBe("*Bold* plain\n");
353+
354+
const emphasis = mounted.view.dom.querySelector("em");
355+
356+
expect(emphasis).toBeInTheDocument();
357+
358+
setSelectionAtTextEnd(mounted.view, emphasis as HTMLElement);
359+
360+
expect(hasActiveInlineSourceProjection(mounted.view.state)).toBe(true);
361+
expect(await runCommand(mounted, "edit.undo")).toBe(true);
362+
expect(mounted.getMarkdown()).toBe("**Bold** plain\n");
363+
expect(await runCommand(mounted, "edit.redo")).toBe(true);
364+
expect(mounted.getMarkdown()).toBe("*Bold* plain\n");
365+
});
366+
367+
it.each([
368+
{ commandId: "format.strong" as const, selector: "strong" },
369+
{ commandId: "format.emphasis" as const, selector: "em" },
370+
])(
371+
"preserves native undo after applying $commandId to a whole paragraph",
372+
async ({ commandId, selector }) => {
373+
const mounted = await mountEditor("Plain paragraph");
374+
375+
expect(runEditorCommand(mounted.editor, "edit.selectAll")).toBe(true);
376+
expect(runEditorCommand(mounted.editor, commandId)).toBe(true);
377+
378+
const formatted = mounted.view.dom.querySelector(selector);
379+
380+
expect(formatted).toBeInTheDocument();
381+
382+
setSelectionAtTextEnd(mounted.view, formatted as HTMLElement);
383+
384+
expect(hasActiveInlineSourceProjection(mounted.view.state)).toBe(true);
385+
expect(await runCommand(mounted, "edit.undo")).toBe(true);
386+
expect(mounted.getMarkdown()).toBe("Plain paragraph\n");
387+
expect(await runCommand(mounted, "edit.redo")).toBe(true);
388+
expect(mounted.view.dom.querySelector(selector)).toHaveTextContent("Plain paragraph");
389+
},
390+
);
391+
331392
it("tracks real source edits as dirty without counting projection entry or commit", async () => {
332393
const onContentTransaction = vi.fn();
333394
const mounted = await mountEditor("**Bold** plain", onContentTransaction);
@@ -451,20 +512,19 @@ describe("inline source projection", () => {
451512
expect(mounted.view.state.doc.textContent).toBe("**Bolder** plain");
452513
});
453514

454-
it("keeps native undo from running through an active projection", async () => {
515+
it("finalizes a clean active projection before running native undo and redo", async () => {
455516
const mounted = await mountEditor("**Bold** plain");
456517

457518
setSelectionAtDocumentEnd(mounted.view);
458519
typeText(mounted.view, "!");
459520
enterProjection(mounted, "strong");
460521

461522
expect(await runCommand(mounted, "edit.undo")).toBe(true);
462-
expect(hasActiveInlineSourceProjection(mounted.view.state)).toBe(true);
463-
expect(mounted.view.state.doc.textContent).toBe("**Bold** plain!");
523+
expect(hasActiveInlineSourceProjection(mounted.view.state)).toBe(false);
524+
expect(mounted.getMarkdown()).toBe("**Bold** plain\n");
464525

465526
expect(await runCommand(mounted, "edit.redo")).toBe(true);
466-
expect(hasActiveInlineSourceProjection(mounted.view.state)).toBe(true);
467-
expect(mounted.view.state.doc.textContent).toBe("**Bold** plain!");
527+
expect(mounted.getMarkdown()).toBe("**Bold** plain!\n");
468528
});
469529

470530
it("preserves native undo and redo after projection commit", async () => {

src/features/editor/plugins/inlineSourceProjection.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ export const canRedoInlineSourceProjection = (state: EditorState) => {
167167
return Boolean(session && session.redoStack.length > 0);
168168
};
169169

170+
export const canDeferInlineSourceProjectionToNativeHistory = (state: EditorState) => {
171+
const session = getInlineSourceProjectionState(state).session;
172+
173+
return Boolean(session && isCleanProjectionSession(state, session));
174+
};
175+
170176
export const undoInlineSourceProjection = (view: EditorView) => {
171177
const session = getInlineSourceProjectionState(view.state).session;
172178
const source = session?.undoStack.at(-1);
@@ -176,6 +182,12 @@ export const undoInlineSourceProjection = (view: EditorView) => {
176182
}
177183

178184
if (source === undefined) {
185+
if (isCleanProjectionSession(view.state, session)) {
186+
finalizeInlineSourceProjection(view);
187+
188+
return false;
189+
}
190+
179191
return true;
180192
}
181193

@@ -202,6 +214,12 @@ export const redoInlineSourceProjection = (view: EditorView) => {
202214
}
203215

204216
if (source === undefined) {
217+
if (isCleanProjectionSession(view.state, session)) {
218+
finalizeInlineSourceProjection(view);
219+
220+
return false;
221+
}
222+
205223
return true;
206224
}
207225

@@ -891,7 +909,9 @@ const createEnterProjectionTransaction = (state: EditorState, range: ActiveProje
891909
to: range.from + originalSource.length,
892910
undoStack: [],
893911
} satisfies ProjectionSession;
894-
const transaction = state.tr.replaceWith(range.from, range.to, state.schema.text(originalSource));
912+
const transaction = state.tr
913+
.replaceWith(range.to, range.to, state.schema.text(sourceMarkers.closing))
914+
.replaceWith(range.from, range.from, state.schema.text(sourceMarkers.opening));
895915

896916
transaction
897917
.setSelection(TextSelection.create(transaction.doc, selectionPosition))
@@ -926,6 +946,21 @@ const createFinalizeRestoreTransaction = (
926946
replacement.text.length,
927947
parsed,
928948
);
949+
const suppressAt =
950+
shouldSuppressProjectionAtSelection && restoreSelection.anchor === restoreSelection.head
951+
? restoreSelection.anchor
952+
: null;
953+
954+
if (source === session.originalSource && parsed.type === "mark") {
955+
return createCleanFinalizeRestoreTransaction(
956+
state,
957+
session,
958+
parsed,
959+
restoreSelection,
960+
suppressAt,
961+
);
962+
}
963+
929964
const pendingCommit =
930965
source === session.originalSource
931966
? null
@@ -955,10 +990,45 @@ const createFinalizeRestoreTransaction = (
955990
.setMeta("addToHistory", false)
956991
.setMeta(leafdownInlineSourceProjectionPluginKey, {
957992
pendingCommit,
958-
suppressAt:
959-
shouldSuppressProjectionAtSelection && restoreSelection.anchor === restoreSelection.head
960-
? restoreSelection.anchor
961-
: null,
993+
suppressAt,
994+
type: "finalizeRestore",
995+
} satisfies ProjectionMeta)
996+
.scrollIntoView();
997+
998+
return transaction;
999+
};
1000+
1001+
const createCleanFinalizeRestoreTransaction = (
1002+
state: EditorState,
1003+
session: ProjectionSession,
1004+
parsed: Extract<ParsedProjectionSource, { type: "mark" }>,
1005+
restoreSelection: { anchor: number; head: number },
1006+
suppressAt: number | null,
1007+
) => {
1008+
const transaction = state.tr;
1009+
1010+
transaction
1011+
.delete(session.to - parsed.closing.length, session.to)
1012+
.delete(session.from, session.from + parsed.opening.length);
1013+
1014+
const markFrom = session.from;
1015+
const markTo = session.from + session.originalText.length;
1016+
1017+
if (markFrom < markTo) {
1018+
for (const mark of session.marks) {
1019+
transaction.addMark(markFrom, markTo, state.schema.marks[mark.markName].create(mark.attrs));
1020+
}
1021+
}
1022+
1023+
transaction
1024+
.setSelection(
1025+
TextSelection.create(transaction.doc, restoreSelection.anchor, restoreSelection.head),
1026+
)
1027+
.setStoredMarks([])
1028+
.setMeta("addToHistory", false)
1029+
.setMeta(leafdownInlineSourceProjectionPluginKey, {
1030+
pendingCommit: null,
1031+
suppressAt,
9621032
type: "finalizeRestore",
9631033
} satisfies ProjectionMeta)
9641034
.scrollIntoView();
@@ -1493,6 +1563,9 @@ const mapProjectionSession = (session: ProjectionSession, transaction: Transacti
14931563
const getProjectionSource = (state: EditorState, session: ProjectionSession) =>
14941564
state.doc.textBetween(session.from, session.to, "\n", "\n");
14951565

1566+
const isCleanProjectionSession = (state: EditorState, session: ProjectionSession) =>
1567+
getProjectionSource(state, session) === session.originalSource;
1568+
14961569
const getSourceMarkers = (marks: ProjectionMarkDescriptor[]) => {
14971570
const strong = marks.find((mark) => mark.markName === "strong");
14981571
const emphasis = marks.find((mark) => mark.markName === "emphasis");

src/features/editor/utils/editorCommandState.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe("editor command state", () => {
6969
expect(getEditorCommandState(mounted.view).enabledCommands["edit.redo"]).toBe(true);
7070
});
7171

72-
it("uses projection-local history availability while projection is active", async () => {
72+
it("uses projection-local history and native history availability while projection is active", async () => {
7373
const mounted = await mountEditor("**Bold** plain");
7474
const strong = mounted.view.dom.querySelector("strong");
7575

@@ -79,7 +79,7 @@ describe("editor command state", () => {
7979
typeText(mounted.view, "!");
8080
setSelectionAtTextEnd(mounted.view, strong as HTMLElement);
8181

82-
expect(getEditorCommandState(mounted.view).enabledCommands["edit.undo"]).toBe(false);
82+
expect(getEditorCommandState(mounted.view).enabledCommands["edit.undo"]).toBe(true);
8383
expect(getEditorCommandState(mounted.view).enabledCommands["edit.redo"]).toBe(false);
8484

8585
typeText(mounted.view, "er");

src/features/editor/utils/editorCommandState.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import type { EditorView } from "@milkdown/kit/prose/view";
66
import type { AppCommandId, EditorCommandState } from "@/features/commands/types";
77

88
import {
9+
canDeferInlineSourceProjectionToNativeHistory,
910
canRedoInlineSourceProjection,
10-
hasActiveInlineSourceProjection,
1111
canUndoInlineSourceProjection,
12+
hasActiveInlineSourceProjection,
1213
} from "../plugins/inlineSourceProjection";
1314
import {
1415
canDecreaseListIndent,
@@ -234,9 +235,16 @@ export const getEditorCommandState = (view: EditorView): EditorCommandState => {
234235
const hasWordBeforeSelection = Boolean(getTextWordRangeBeforeSelection(state));
235236
const hasTableSelection = hasTableContext(state);
236237
const hasActiveProjection = hasActiveInlineSourceProjection(state);
238+
const canDeferProjectionToNativeHistory = canDeferInlineSourceProjectionToNativeHistory(state);
237239
const enabledCommands: Partial<Record<AppCommandId, boolean>> = {
238-
"edit.undo": hasActiveProjection ? canUndoInlineSourceProjection(state) : undoDepth(state) > 0,
239-
"edit.redo": hasActiveProjection ? canRedoInlineSourceProjection(state) : redoDepth(state) > 0,
240+
"edit.undo": hasActiveProjection
241+
? canUndoInlineSourceProjection(state) ||
242+
(canDeferProjectionToNativeHistory && undoDepth(state) > 0)
243+
: undoDepth(state) > 0,
244+
"edit.redo": hasActiveProjection
245+
? canRedoInlineSourceProjection(state) ||
246+
(canDeferProjectionToNativeHistory && redoDepth(state) > 0)
247+
: redoDepth(state) > 0,
240248
};
241249

242250
for (const commandId of activeEditorCommands) {

src/features/editor/utils/editorCommands.test.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,28 +32,38 @@ const mountEditor = async (initialMarkdown: string): Promise<MountedMilkdownEdit
3232

3333
const textContent = (mounted: MountedMilkdownEditor) => mounted.view.state.doc.textContent;
3434
const getTextPosition = (mounted: MountedMilkdownEditor, text: string) => {
35-
let position: number | null = null;
35+
const textRanges: { end: number; from: number; start: number }[] = [];
36+
let documentText = "";
3637

3738
mounted.view.state.doc.descendants((node, pos) => {
3839
if (!node.isText) {
3940
return true;
4041
}
4142

42-
const index = node.textContent.indexOf(text);
43-
44-
if (index === -1) {
45-
return true;
46-
}
43+
const start = documentText.length;
44+
documentText += node.textContent;
45+
textRanges.push({
46+
end: documentText.length,
47+
from: pos,
48+
start,
49+
});
4750

48-
position = pos + index;
49-
return false;
51+
return true;
5052
});
5153

52-
if (position === null) {
54+
const index = documentText.indexOf(text);
55+
56+
if (index === -1) {
5357
throw new Error(`Could not find text: ${text}`);
5458
}
5559

56-
return position;
60+
const range = textRanges.find(({ end, start }) => start <= index && index < end);
61+
62+
if (!range) {
63+
throw new Error(`Could not resolve text position: ${text}`);
64+
}
65+
66+
return range.from + index - range.start;
5767
};
5868
const textSelectionStart = 1;
5969
const imageMarkerText = "![]()";
@@ -275,7 +285,7 @@ describe("editor commands", () => {
275285
);
276286

277287
expect(markdownEditor.view.dom).toHaveTextContent("**Bold**");
278-
expect(markdownEditor.view.dom.querySelector("strong")).not.toBeInTheDocument();
288+
expect(markdownEditor.view.dom.querySelector("strong")).toBeInTheDocument();
279289
expect(markdownEditor.getMarkdown()).toBe("**Bold**\n");
280290
});
281291

0 commit comments

Comments
 (0)