From afae082d700871301e436bcefbe5055399431b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 20 Sep 2026 14:02:56 -0300 Subject: [PATCH] Finalize source projections before unrelated changes --- .../editor/plugins/sourceProjection.test.ts | 50 ++++++++++++++++++- .../editor/plugins/sourceProjection.ts | 29 +++++++++-- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/features/editor/plugins/sourceProjection.test.ts b/src/features/editor/plugins/sourceProjection.test.ts index 76c33193..33d96593 100644 --- a/src/features/editor/plugins/sourceProjection.test.ts +++ b/src/features/editor/plugins/sourceProjection.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { NodeSelection } from "@milkdown/kit/prose/state"; +import { NodeSelection, Selection } from "@milkdown/kit/prose/state"; import { describe, expect, it, vi } from "vitest"; import { EDITOR_TEST_ROOT_CLASS_NAME } from "@/test/factories/editor"; @@ -25,6 +25,7 @@ import { enterProjection, selectFootnoteReference } from "@/test/utils/sourcePro import { runEditorCommand } from "../commands"; import { + finalizeSourceProjection, getActiveSourceProjectionRange, hasActiveSourceProjection, pasteIntoSourceProjection, @@ -48,6 +49,14 @@ const waitForMarkdownUpdateListener = async () => { const runCommand = async (mounted: MountedMilkdownEditor, commandId: "edit.redo" | "edit.undo") => runEditorCommand(mounted.editor, commandId); +const appendTextOutsideProjection = (mounted: MountedMilkdownEditor, text: string) => { + const position = getEditorTextPosition(mounted, "plain") + "plain".length; + const transaction = mounted.view.state.tr.insertText(text, position); + + transaction.setSelection(Selection.atEnd(transaction.doc)); + mounted.view.dispatch(transaction); +}; + // ProseMirror finishes a composition on a timer and reconciles the DOM when it fires. Without the // wait that lands after the editor is gone. const PROSEMIRROR_COMPOSITION_END_MS = 20; @@ -67,6 +76,45 @@ const composeText = async (mounted: MountedMilkdownEditor, position: number, tex }; describe("source projection", () => { + describe("unrelated document changes", () => { + it("finalizes a projection before an unrelated change moves the selection outside it", async () => { + const onContentChanged = vi.fn(); + const explicitlyFinalized = await mountProjectionEditor(BOLD_PLAIN_MARKDOWN); + const automaticallyFinalized = await mountProjectionEditor(BOLD_PLAIN_MARKDOWN, { + onContentChanged, + }); + + enterProjection(explicitlyFinalized, "strong"); + expect(finalizeSourceProjection(explicitlyFinalized.view)).toBe(true); + appendTextOutsideProjection(explicitlyFinalized, "!"); + + enterProjection(automaticallyFinalized, "strong"); + appendTextOutsideProjection(automaticallyFinalized, "!"); + + expect(hasActiveSourceProjection(automaticallyFinalized.view.state)).toBe(false); + expect(automaticallyFinalized.view.state.doc.toJSON()).toEqual( + explicitlyFinalized.view.state.doc.toJSON(), + ); + expect(automaticallyFinalized.view.state.selection.toJSON()).toEqual( + explicitlyFinalized.view.state.selection.toJSON(), + ); + expect(automaticallyFinalized.getMarkdown()).toBe(explicitlyFinalized.getMarkdown()); + expect(onContentChanged).toHaveBeenCalledTimes(1); + + expect(await runCommand(automaticallyFinalized, "edit.undo")).toBe(true); + expect(await runCommand(explicitlyFinalized, "edit.undo")).toBe(true); + expect(automaticallyFinalized.view.state.doc.toJSON()).toEqual( + explicitlyFinalized.view.state.doc.toJSON(), + ); + + expect(await runCommand(automaticallyFinalized, "edit.redo")).toBe(true); + expect(await runCommand(explicitlyFinalized, "edit.redo")).toBe(true); + expect(automaticallyFinalized.view.state.doc.toJSON()).toEqual( + explicitlyFinalized.view.state.doc.toJSON(), + ); + }); + }); + describe("entry and rendering", () => { it("projects strong markers as real editable document text", async () => { const mounted = await mountProjectionEditor(BOLD_PLAIN_MARKDOWN); diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 45823802..2cd6afb5 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -138,6 +138,13 @@ export const createSourceProjectionProsePlugin = (adapters: readonly SourceProje // before the history plugin reads the meta. filterTransaction: (transaction, state) => { if (transaction.docChanged && hasActiveSourceProjection(state)) { + const { session } = getSourceProjectionState(state); + + // The appended clean restore maps this native history event onto canonical content. + if (session && shouldFinalizeProjectionAfterTransaction(transaction, state, session)) { + return true; + } + transaction.setMeta("addToHistory", false); if (!getProjectionMeta(transaction)) { @@ -899,10 +906,7 @@ const applyProjectionSessionState = ( }; } - if ( - transaction.docChanged && - (!isRangeInside(newState.selection, session) || !isProjectionRangeFlatText(newState, session)) - ) { + if (transaction.docChanged && !isProjectionRangeFlatText(newState, session)) { return { isLinkLabelHovered: false, pendingCommit: null, @@ -1731,7 +1735,7 @@ const isRangeInside = (range: TextRange, bounds: TextRange) => // node back as a newline. A hard break is the only node that survives that reading, since it // already stands for the newline it reports; any other node would commit as a line break the // author never wrote. -const isProjectionRangeFlatText = (state: EditorState, { from, to }: TextRange) => { +const isProjectionRangeFlatText = (state: Pick, { from, to }: TextRange) => { let isFlatText = state.doc.resolve(from).sameParent(state.doc.resolve(to)); state.doc.nodesBetween(from, to, (node) => { @@ -1745,6 +1749,21 @@ const isProjectionRangeFlatText = (state: EditorState, { from, to }: TextRange) return isFlatText; }; +const shouldFinalizeProjectionAfterTransaction = ( + transaction: Transaction, + state: EditorState, + session: ProjectionSession, +) => { + const mappedSession = mapProjectionSession(session, transaction); + + return ( + !isRangeInside(transaction.selection, mappedSession) && + isProjectionRangeFlatText(transaction, mappedSession) && + getProjectionSource(state, session) === + getTextBetween(transaction.doc, mappedSession.from, mappedSession.to) + ); +}; + const mapProjectionSession = (session: ProjectionSession, transaction: Transaction) => { if (!transaction.docChanged) { return session;