Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion src/features/editor/plugins/sourceProjection.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +25,7 @@ import { enterProjection, selectFootnoteReference } from "@/test/utils/sourcePro

import { runEditorCommand } from "../commands";
import {
finalizeSourceProjection,
getActiveSourceProjectionRange,
hasActiveSourceProjection,
pasteIntoSourceProjection,
Expand All @@ -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;
Expand All @@ -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);
Expand Down
29 changes: 24 additions & 5 deletions src/features/editor/plugins/sourceProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<EditorState, "doc">, { from, to }: TextRange) => {
let isFlatText = state.doc.resolve(from).sameParent(state.doc.resolve(to));

state.doc.nodesBetween(from, to, (node) => {
Expand All @@ -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;
Expand Down