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.
77 lines
2.3 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|