memos/web/tests/editor-controller.test.tsx
boojack 5f7e038aa7 fix(editor): support backslash-escaping literal #tags
Memos layers #tag syntax on top of CommonMark, but there was no way to
write a literal "#NAS" — it always became a clickable tag (issue #6035).

Handle escapes lexically, the way TipTap/prosemirror-markdown do, with no
"escaped tag" node in the document model:

- Parse: marked's built-in backslash escape already turns "\#NAS" into
  plain text; the read-only renderer (remark-tag) now honors the same
  escape by lexing from the original source slice and falling back to the
  prior value-based parse when it can't faithfully reconstruct the text.
- Serialize: a tag-aware Markdown extension (tagMarkdown.ts) backslash-
  escapes a "#" that would re-parse into a tag, skipping links and code
  where tags never form.
- The editor's #tag lexing moves to the canonical @tiptap/markdown
  markdownTokenizer; tag-in-link skipping moves to a tree pass so the
  editor and renderer agree.

The capped tag-run grammar is consolidated into utils/tag-grammar.ts
(TAG_RUN), shared by the tokenizer, the serialize-escape, and the
renderer so they can't drift. README documents why the markdown manager
is worked around in three places.
2026-06-23 23:00:54 +08:00

249 lines
11 KiB
TypeScript

import { act, fireEvent, render } from "@testing-library/react";
import type { Editor as EditorInstance } from "@tiptap/core";
import { AllSelection } from "@tiptap/pm/state";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import Editor from "@/components/MemoEditor/Editor";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
vi.mock("@/hooks/useUserQueries", async (importOriginal) => ({
...(await importOriginal<object>()),
useTagCounts: () => ({ data: {} }),
}));
function setup(initialContent = "") {
const ref = createRef<EditorController>();
const onContentChange = vi.fn();
const { container } = render(
<Editor ref={ref} initialContent={initialContent} placeholder="memo" onContentChange={onContentChange} onPaste={vi.fn()} />,
);
// The editor instance is exposed on the ProseMirror DOM node; tests use it
// only where the EditorController surface cannot produce the state.
const editor = (container.querySelector(".ProseMirror") as HTMLElement & { editor?: EditorInstance }).editor as EditorInstance;
return { ref, onContentChange, editor };
}
// Like setup(), but exposes a rerender(content) that re-renders the SAME
// component instance with a new initialContent — the external-sync path.
function setupRerenderable(initialContent = "") {
const ref = createRef<EditorController>();
const onContentChange = vi.fn();
const renderEditor = (content: string) => (
<Editor ref={ref} initialContent={content} placeholder="memo" onContentChange={onContentChange} onPaste={vi.fn()} />
);
const { rerender: rtlRerender } = render(renderEditor(initialContent));
const rerender = (content: string) => act(() => rtlRerender(renderEditor(content)));
return { ref, onContentChange, rerender };
}
describe("Editor EditorController", () => {
it("loads markdown and serializes it back", () => {
const { ref } = setup("# Title\n\nSome **bold** text.");
expect(ref.current?.getMarkdown()).toBe("# Title\n\nSome **bold** text.");
});
it("reports emptiness", () => {
const { ref } = setup("");
expect(ref.current?.isEmpty()).toBe(true);
});
it("treats whitespace-only content as empty", () => {
// Markdown parsing can never produce a whitespace-only text node, so type
// the spaces through a raw ProseMirror transaction the way a user would.
const { ref, editor } = setup("");
act(() => {
editor.view.dispatch(editor.state.tr.insertText(" ", 1));
});
expect(editor.isEmpty).toBe(false); // structurally non-empty...
expect(ref.current?.isEmpty()).toBe(true); // ...but empty per the contract
});
it("setMarkdown replaces the document", () => {
const { ref } = setup("old");
act(() => ref.current?.setMarkdown("- [ ] task"));
expect(ref.current?.getMarkdown()).toBe("- [ ] task");
});
it("insertMarkdown adds content and notifies onContentChange", () => {
const { ref, onContentChange } = setup("");
act(() => ref.current?.insertMarkdown("hello"));
expect(ref.current?.getMarkdown()).toContain("hello");
expect(onContentChange).toHaveBeenCalledWith(expect.stringContaining("hello"));
});
it("formatting.run('bold') bolds the selected text", () => {
const { ref } = setup("bold me");
act(() => ref.current?.selectAll());
act(() => ref.current?.formatting?.run("bold"));
expect(ref.current?.getMarkdown()).toBe("**bold me**");
});
it("formatting.run('taskList') converts the current block", () => {
const { ref } = setup("buy milk");
act(() => ref.current?.formatting?.run("taskList"));
expect(ref.current?.getMarkdown()).toBe("- [ ] buy milk");
});
});
describe("tag suggestion insertion", () => {
it("tag suggestion command inserts tag-marked text plus an unmarked space", () => {
const { ref, editor } = setup("");
act(() => {
editor
.chain()
.focus()
.insertContentAt({ from: 1, to: 1 }, [
{ type: "text", text: "#alpha", marks: [{ type: "tag", attrs: { tag: "alpha" } }] },
{ type: "text", text: " " },
])
.run();
});
expect(ref.current?.getMarkdown()).toBe("#alpha");
const para = editor.getJSON().content?.[0];
expect(para?.content?.[0]).toMatchObject({ text: "#alpha", marks: [{ type: "tag" }] });
expect(para?.content?.[1]?.marks ?? []).toHaveLength(0);
});
});
describe("tag escaping", () => {
// Drives the ProseMirror input-rules plugin the way real typing does:
// someProp("handleTextInput", …) is exactly what the view calls on keystroke.
function typeText(editor: EditorInstance, text: string) {
const { from } = editor.state.selection;
editor.view.someProp("handleTextInput", (handler) => handler(editor.view, from, from, text));
}
it("undoing the tag autoformat leaves a durable literal #tag, not a pill", () => {
const { ref, editor } = setup("");
act(() => {
editor.view.dispatch(editor.state.tr.insertText("#NAS", 1));
});
act(() => typeText(editor, " ")); // tag input rule fires → pill
act(() => {
editor.commands.undoInputRule(); // the standard "I didn't want that" gesture
});
// Back to plain text — and the serializer escapes it so it can't re-tag.
expect(ref.current?.getMarkdown()?.trim()).toBe("\\#NAS");
const para = editor.getJSON().content?.[0];
expect(para?.content?.some((n) => (n.marks ?? []).some((m) => m.type === "tag"))).toBeFalsy();
});
it("a plain #tag still becomes a tag", () => {
const { ref, editor } = setup("");
act(() => {
editor.view.dispatch(editor.state.tr.insertText("#work", 1));
});
act(() => typeText(editor, " "));
expect(ref.current?.getMarkdown()?.trim()).toBe("#work");
const para = editor.getJSON().content?.[0];
expect(para?.content?.[0]).toMatchObject({ text: "#work", marks: [{ type: "tag" }] });
});
});
describe("external content sync", () => {
it("a trim-equal echo of the editor's own output does not reset the document", () => {
const { ref, rerender } = setupRerenderable("hello");
// simulate parent echoing back the serialized value
rerender("hello");
expect(ref.current?.getMarkdown()).toBe("hello");
});
it("a genuinely external change replaces the document without emitting onContentChange", () => {
const { ref, onContentChange, rerender } = setupRerenderable("old");
rerender("# new");
expect(ref.current?.getMarkdown()).toBe("# new");
expect(onContentChange).not.toHaveBeenCalled();
});
it("reset-to-empty clears the editor", () => {
const { ref, rerender } = setupRerenderable("something");
rerender("");
expect(ref.current?.isEmpty()).toBe(true);
});
});
describe("keyboard shortcuts", () => {
it("Mod-Enter does not insert a hard break (reserved for app-level save)", () => {
// ProseMirror attaches its keydown listener directly on the contenteditable
// (.ProseMirror) element. fireEvent.keyDown dispatches a real DOM event
// that triggers PM's handler → keymap plugin resolution → our
// SaveShortcutPassthrough extension (priority 1000) returns true before
// HardBreak's handler runs, so no document mutation occurs.
//
// Non-vacuousness proof: the parallel Shift-Enter test below uses the same
// dispatch path and DOES mutate the document, confirming that fireEvent.keyDown
// reaches ProseMirror's keymap in jsdom.
const { ref, editor } = setup("hello");
const proseMirrorEl = document.querySelector(".ProseMirror") as HTMLElement;
const docBefore = editor.state.doc.toString();
act(() => {
fireEvent.keyDown(proseMirrorEl, { key: "Enter", ctrlKey: true });
});
expect(editor.state.doc.toString()).toBe(docBefore);
expect(ref.current?.getMarkdown()).toBe("hello");
});
it("Shift-Enter still inserts a hard break (proves fireEvent.keyDown reaches PM keymap)", () => {
// Positive control: the same keydown dispatch path DOES mutate the document
// when it reaches HardBreak's handler (Shift-Enter is not intercepted by
// SaveShortcutPassthrough, which only swallows Mod-Enter).
// This confirms the Mod-Enter test above is not vacuous.
const { ref, editor } = setup("hello");
// Select all first so the cursor is in a known position and PM's
// setHardBreak command can execute (requires a text selection).
act(() => {
editor.commands.selectAll();
});
const proseMirrorEl = document.querySelector(".ProseMirror") as HTMLElement;
const docBefore = editor.state.doc.toString();
act(() => {
fireEvent.keyDown(proseMirrorEl, { key: "Enter", shiftKey: true });
});
// A hardBreak node must have been inserted — the document changed.
expect(editor.state.doc.toString()).not.toBe(docBefore);
expect(editor.state.doc.toString()).toContain("hardBreak");
// The serialized markdown now contains a hard break (two trailing spaces + newline).
expect(ref.current?.getMarkdown()).not.toBe("hello");
});
});
describe("select-all then delete", () => {
it("collapses to a caret instead of leaving a highlighted empty block", () => {
// Repro of the reported bug: Ctrl+A makes an AllSelection, Backspace empties
// the document. AllSelection.map() always returns another AllSelection, so
// without intervention the selection is left as a non-empty whole-document
// AllSelection over the empty paragraph — which the view paints as a
// "selected" empty block (the screenshot artifact).
const { ref, editor } = setup("hello world");
const proseMirrorEl = document.querySelector(".ProseMirror") as HTMLElement;
act(() => {
editor.commands.selectAll();
});
expect(editor.state.selection).toBeInstanceOf(AllSelection);
act(() => {
fireEvent.keyDown(proseMirrorEl, { key: "Backspace" });
});
expect(ref.current?.isEmpty()).toBe(true);
// The fix: the leftover selection must be a collapsed caret, never a
// lingering non-empty AllSelection.
expect(editor.state.selection.empty).toBe(true);
expect(editor.state.selection).not.toBeInstanceOf(AllSelection);
});
it("deleting an all-selection via the deleteSelection command also collapses (covers Cut)", () => {
// Cut (Cmd-X) and programmatic deletes go through deleteSelection rather
// than the Backspace keymap, but hit the same AllSelection.map() trap.
const { ref, editor } = setup("# Title\n\nsome **bold** text");
act(() => {
editor.commands.selectAll();
});
act(() => {
editor.commands.deleteSelection();
});
expect(ref.current?.isEmpty()).toBe(true);
expect(editor.state.selection.empty).toBe(true);
expect(editor.state.selection).not.toBeInstanceOf(AllSelection);
});
});