memos/web/tests/suggestion-extension.test.ts
boojack 1e3ec38fa1 refactor(editor): modularize formatting and simplify editor state
Behavior-preserving restructure of the MemoEditor subsystem for simplicity
and extensibility:

- Add a data-driven command catalog (editorCommands.ts) as the single source
  for formatting verbs; the toolbar, active-state hook, and WYSIWYG handle all
  derive from it, so adding a verb is a one-file change.
- Add a createSuggestionExtension factory; TagSuggestion consumes it, so new
  `/` or `@` triggers reuse the shared popup in ~10 lines.
- Collapse FormattingController into a single EditorController with an optional
  `formatting` capability; PlainEditor is an honest textarea fallback (no faked
  formatting); replace stringly-typed isActive with typed getActiveFormats.
- Move audio-recorder state out of the reducer into useAudioRecorder, keeping a
  single recorderBusy flag in the store.
- Convert the editor context to an external store (useSyncExternalStore) with
  per-slice subscriptions, so typing no longer re-renders the toolbar, insert
  menu, or metadata.
- Extract the shared #tag lexing grammar (tag-grammar.ts) used by both the
  editor tokenizer and the remark renderer.
- Remove dead reducer actions/cases and fix a preview blob-URL leak on the
  upload path.

Add tests for the command catalog, suggestion factory, and autosave.
2026-06-22 23:51:59 +08:00

77 lines
2.3 KiB
TypeScript

import { Editor } from "@tiptap/core";
import { describe, expect, it } from "vitest";
import { buildExtensions } from "@/components/MemoEditor/Editor/extensions";
import { createSuggestionExtension } from "@/components/MemoEditor/Editor/suggestionExtension";
describe("createSuggestionExtension", () => {
it("creates a Tiptap extension with the configured name", () => {
const ext = createSuggestionExtension<string>({
name: "fooSuggestion",
char: "@",
items: () => [],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
});
expect(ext.name).toBe("fooSuggestion");
});
it("contributes its suggestion plugin to the editor", () => {
const base = new Editor({ extensions: buildExtensions(), content: "", contentType: "markdown" });
const baseline = base.state.plugins.length;
base.destroy();
const withSuggestion = new Editor({
extensions: [
...buildExtensions(),
createSuggestionExtension<string>({
name: "pluginProbe",
char: "@",
items: () => [],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
}),
],
content: "",
contentType: "markdown",
});
try {
expect(withSuggestion.state.plugins.length).toBeGreaterThan(baseline);
} finally {
withSuggestion.destroy();
}
});
it("lets multiple triggers coexist in one editor without plugin-key collision", () => {
const tagLike = createSuggestionExtension<string>({
name: "tagLike",
char: "#",
items: () => ["alpha"],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
});
const slashLike = createSuggestionExtension<string>({
name: "slashLike",
char: "/",
items: () => ["heading"],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
});
const editor = new Editor({
extensions: [...buildExtensions(), tagLike, slashLike],
content: "",
contentType: "markdown",
});
try {
const names = editor.extensionManager.extensions.map((e) => e.name);
expect(names).toContain("tagLike");
expect(names).toContain("slashLike");
} finally {
editor.destroy();
}
});
});