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.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EDITOR_COMMANDS, EDITOR_COMMANDS_BY_ID, getActiveFormats } from "@/components/MemoEditor/Editor/editorCommands";
|
||||
import { buildExtensions } from "@/components/MemoEditor/Editor/extensions";
|
||||
|
||||
function makeEditor(content = "") {
|
||||
return new Editor({ extensions: buildExtensions(), content, contentType: "markdown" });
|
||||
}
|
||||
|
||||
describe("editor command catalog", () => {
|
||||
it("exposes every command keyed by id", () => {
|
||||
for (const command of EDITOR_COMMANDS) {
|
||||
expect(EDITOR_COMMANDS_BY_ID[command.id]).toBe(command);
|
||||
}
|
||||
});
|
||||
|
||||
it("bold command toggles the bold mark, reflected by getActiveFormats", () => {
|
||||
const editor = makeEditor("hello");
|
||||
try {
|
||||
editor.commands.selectAll();
|
||||
expect(getActiveFormats(editor).bold).toBe(false);
|
||||
EDITOR_COMMANDS_BY_ID.bold.run(editor);
|
||||
expect(getActiveFormats(editor).bold).toBe(true);
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("heading2 command sets the level, reported by getActiveFormats.headingLevel", () => {
|
||||
const editor = makeEditor("title");
|
||||
try {
|
||||
expect(getActiveFormats(editor).headingLevel).toBeNull();
|
||||
EDITOR_COMMANDS_BY_ID.heading2.run(editor);
|
||||
expect(getActiveFormats(editor).headingLevel).toBe(2);
|
||||
EDITOR_COMMANDS_BY_ID.paragraph.run(editor);
|
||||
expect(getActiveFormats(editor).headingLevel).toBeNull();
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("taskList command toggles a task list", () => {
|
||||
const editor = makeEditor("buy milk");
|
||||
try {
|
||||
EDITOR_COMMANDS_BY_ID.taskList.run(editor);
|
||||
expect(getActiveFormats(editor).taskList).toBe(true);
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("link command applies a link from ctx.url and clears it when already active", () => {
|
||||
const editor = makeEditor("memos");
|
||||
try {
|
||||
editor.commands.selectAll();
|
||||
EDITOR_COMMANDS_BY_ID.link.run(editor, { url: "https://usememos.com" });
|
||||
expect(getActiveFormats(editor).link).toBe(true);
|
||||
editor.commands.selectAll();
|
||||
EDITOR_COMMANDS_BY_ID.link.run(editor); // active → unset (ignores missing url)
|
||||
expect(getActiveFormats(editor).link).toBe(false);
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -71,16 +71,16 @@ describe("Editor EditorController", () => {
|
||||
expect(onContentChange).toHaveBeenCalledWith(expect.stringContaining("hello"));
|
||||
});
|
||||
|
||||
it("toggleBold bolds the selected text", () => {
|
||||
it("formatting.run('bold') bolds the selected text", () => {
|
||||
const { ref } = setup("bold me");
|
||||
act(() => ref.current?.selectAll());
|
||||
act(() => ref.current?.toggleBold());
|
||||
act(() => ref.current?.formatting?.run("bold"));
|
||||
expect(ref.current?.getMarkdown()).toBe("**bold me**");
|
||||
});
|
||||
|
||||
it("toggleTaskList converts the current block", () => {
|
||||
it("formatting.run('taskList') converts the current block", () => {
|
||||
const { ref } = setup("buy milk");
|
||||
act(() => ref.current?.toggleTaskList());
|
||||
act(() => ref.current?.formatting?.run("taskList"));
|
||||
expect(ref.current?.getMarkdown()).toBe("- [ ] buy milk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useMemoInit } from "@/components/MemoEditor/hooks";
|
||||
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
|
||||
import { EditorProvider, useEditorSelector } from "@/components/MemoEditor/state";
|
||||
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
@@ -21,8 +21,8 @@ vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
function Harness({ content }: { content: string }) {
|
||||
const memo = create(MemoSchema, { name: "memos/1", content });
|
||||
useMemoInit({ editorRef: { current: null }, memo, username: "users/test" });
|
||||
const { state } = useEditorContext();
|
||||
return <span data-testid="mode">{state.ui.editorMode}</span>;
|
||||
const editorMode = useEditorSelector((s) => s.ui.editorMode);
|
||||
return <span data-testid="mode">{editorMode}</span>;
|
||||
}
|
||||
|
||||
function renderGuard(content: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorContent } from "@/components/MemoEditor/components/EditorContent";
|
||||
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
|
||||
import { EditorProvider, useEditorContext, useEditorSelector } from "@/components/MemoEditor/state";
|
||||
|
||||
vi.mock("@/hooks/useUserQueries", () => ({
|
||||
useTagCounts: () => ({ data: {} }),
|
||||
@@ -11,15 +11,16 @@ vi.mock("@/hooks/useUserQueries", () => ({
|
||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
|
||||
function ModeProbe() {
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { actions, dispatch } = useEditorContext();
|
||||
const editorMode = useEditorSelector((s) => s.ui.editorMode);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="probe-toggle"
|
||||
onClick={() => dispatch(actions.setEditorMode(state.ui.editorMode === "wysiwyg" ? "raw" : "wysiwyg"))}
|
||||
onClick={() => dispatch(actions.setEditorMode(editorMode === "wysiwyg" ? "raw" : "wysiwyg"))}
|
||||
>
|
||||
{state.ui.editorMode}
|
||||
{editorMode}
|
||||
</button>
|
||||
<button type="button" data-testid="probe-set-content" onClick={() => dispatch(actions.updateContent("from **wysiwyg**"))}>
|
||||
set content
|
||||
|
||||
@@ -2,77 +2,79 @@ import { act, render } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Editor from "@/components/MemoEditor/Editor";
|
||||
import type { EditorController, FormattingController } from "@/components/MemoEditor/types/editorController";
|
||||
import type { EditorController } from "@/components/MemoEditor/types/editorController";
|
||||
|
||||
vi.mock("@/hooks/useUserQueries", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useTagCounts: () => ({ data: {} }),
|
||||
}));
|
||||
|
||||
type Handle = EditorController & FormattingController;
|
||||
|
||||
function setup(initialContent = "") {
|
||||
const ref = createRef<Handle>();
|
||||
const ref = createRef<EditorController>();
|
||||
render(<Editor ref={ref} initialContent={initialContent} placeholder="memo" onContentChange={vi.fn()} onPaste={vi.fn()} />);
|
||||
return ref;
|
||||
}
|
||||
|
||||
describe("FormattingController (WYSIWYG)", () => {
|
||||
it("toggleCode wraps the selection in inline code", () => {
|
||||
describe("WYSIWYG formatting capability", () => {
|
||||
it("the WYSIWYG editor exposes a formatting capability", () => {
|
||||
const ref = setup("x");
|
||||
expect(ref.current?.formatting).toBeDefined();
|
||||
});
|
||||
|
||||
it("run('code') wraps the selection in inline code", () => {
|
||||
const ref = setup("code me");
|
||||
act(() => ref.current?.selectAll());
|
||||
act(() => ref.current?.toggleCode());
|
||||
act(() => ref.current?.formatting?.run("code"));
|
||||
expect(ref.current?.getMarkdown()).toBe("`code me`");
|
||||
});
|
||||
|
||||
it("toggleBulletList converts the current block", () => {
|
||||
it("run('bulletList') converts the current block", () => {
|
||||
const ref = setup("item");
|
||||
act(() => ref.current?.toggleBulletList());
|
||||
act(() => ref.current?.formatting?.run("bulletList"));
|
||||
expect(ref.current?.getMarkdown()).toBe("- item");
|
||||
});
|
||||
|
||||
it("toggleOrderedList converts the current block", () => {
|
||||
it("run('orderedList') converts the current block", () => {
|
||||
const ref = setup("item");
|
||||
act(() => ref.current?.toggleOrderedList());
|
||||
act(() => ref.current?.formatting?.run("orderedList"));
|
||||
expect(ref.current?.getMarkdown()).toBe("1. item");
|
||||
});
|
||||
|
||||
it("setHeading makes the block a heading and isActive reflects the level", () => {
|
||||
it("run('heading2') makes the block a heading and getActiveFormats reflects the level", () => {
|
||||
const ref = setup("title");
|
||||
act(() => ref.current?.setHeading(2));
|
||||
act(() => ref.current?.formatting?.run("heading2"));
|
||||
expect(ref.current?.getMarkdown()).toBe("## title");
|
||||
expect(ref.current?.isActive("heading", { level: 2 })).toBe(true);
|
||||
expect(ref.current?.isActive("heading", { level: 1 })).toBe(false);
|
||||
expect(ref.current?.formatting?.getActiveFormats().headingLevel).toBe(2);
|
||||
});
|
||||
|
||||
it("setParagraph reverts a heading", () => {
|
||||
it("run('paragraph') reverts a heading", () => {
|
||||
const ref = setup("# title");
|
||||
act(() => ref.current?.setParagraph());
|
||||
act(() => ref.current?.formatting?.run("paragraph"));
|
||||
expect(ref.current?.getMarkdown()).toBe("title");
|
||||
});
|
||||
|
||||
it("isActive('bold') tracks the bold mark", () => {
|
||||
it("getActiveFormats().bold tracks the bold mark", () => {
|
||||
const ref = setup("x");
|
||||
expect(ref.current?.isActive("bold")).toBe(false);
|
||||
expect(ref.current?.formatting?.getActiveFormats().bold).toBe(false);
|
||||
act(() => ref.current?.selectAll());
|
||||
act(() => ref.current?.toggleBold());
|
||||
expect(ref.current?.isActive("bold")).toBe(true);
|
||||
act(() => ref.current?.formatting?.run("bold"));
|
||||
expect(ref.current?.formatting?.getActiveFormats().bold).toBe(true);
|
||||
});
|
||||
|
||||
it("toggleLink applies a link over the selection, and removes it when active", () => {
|
||||
it("run('link') applies a link over the selection, and removes it when active", () => {
|
||||
const ref = setup("memos");
|
||||
act(() => ref.current?.selectAll());
|
||||
act(() => ref.current?.toggleLink("https://usememos.com"));
|
||||
act(() => ref.current?.formatting?.run("link", { url: "https://usememos.com" }));
|
||||
expect(ref.current?.getMarkdown()).toBe("[memos](https://usememos.com)");
|
||||
act(() => ref.current?.selectAll());
|
||||
act(() => ref.current?.toggleLink());
|
||||
act(() => ref.current?.formatting?.run("link"));
|
||||
expect(ref.current?.getMarkdown()).toBe("memos");
|
||||
});
|
||||
|
||||
it("getSelectedText returns the current selection text", () => {
|
||||
const ref = setup("hello world");
|
||||
act(() => ref.current?.selectAll());
|
||||
expect(ref.current?.getSelectedText()).toBe("hello world");
|
||||
expect(ref.current?.formatting?.getSelectedText()).toBe("hello world");
|
||||
});
|
||||
|
||||
it("subscribe fires on transactions and unsubscribe stops it", () => {
|
||||
@@ -80,14 +82,14 @@ describe("FormattingController (WYSIWYG)", () => {
|
||||
const listener = vi.fn();
|
||||
let unsubscribe = () => {};
|
||||
act(() => {
|
||||
unsubscribe = ref.current!.subscribe(listener);
|
||||
unsubscribe = ref.current!.formatting!.subscribe(listener);
|
||||
});
|
||||
act(() => ref.current?.selectAll());
|
||||
act(() => ref.current?.toggleBold());
|
||||
act(() => ref.current?.formatting?.run("bold"));
|
||||
const callsWhileSubscribed = listener.mock.calls.length;
|
||||
expect(callsWhileSubscribed).toBeGreaterThan(0);
|
||||
act(() => unsubscribe());
|
||||
act(() => ref.current?.toggleItalic());
|
||||
act(() => ref.current?.formatting?.run("italic"));
|
||||
expect(listener.mock.calls.length).toBe(callsWhileSubscribed);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { FormattingToolbar } from "@/components/MemoEditor/components/FormattingToolbar";
|
||||
import type { EditorController, FormattingController } from "@/components/MemoEditor/types/editorController";
|
||||
import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "@/components/MemoEditor/Editor/editorCommands";
|
||||
import type { EditorController } from "@/components/MemoEditor/types/editorController";
|
||||
|
||||
// Match the repo convention: t echoes the i18n key (no i18next backend in tests),
|
||||
// so accessible names below are the keys themselves.
|
||||
@@ -16,87 +17,80 @@ beforeAll(() => {
|
||||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
});
|
||||
|
||||
type Handle = EditorController & FormattingController;
|
||||
|
||||
function makeController(overrides: Partial<Handle> = {}): Handle {
|
||||
const noop = () => {};
|
||||
return {
|
||||
focus: noop,
|
||||
function makeController(opts: { active?: Partial<ActiveFormatState>; getSelectedText?: () => string } = {}) {
|
||||
const run = vi.fn();
|
||||
const activeFormats: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS, ...opts.active };
|
||||
const controller: EditorController = {
|
||||
focus: () => {},
|
||||
hasFocus: () => false,
|
||||
isEmpty: () => true,
|
||||
getMarkdown: () => "",
|
||||
setMarkdown: noop,
|
||||
insertMarkdown: noop,
|
||||
scrollToCursor: noop,
|
||||
selectAll: noop,
|
||||
toggleBold: vi.fn(),
|
||||
toggleItalic: vi.fn(),
|
||||
toggleTaskList: vi.fn(),
|
||||
toggleCode: vi.fn(),
|
||||
toggleBulletList: vi.fn(),
|
||||
toggleOrderedList: vi.fn(),
|
||||
setHeading: vi.fn(),
|
||||
setParagraph: vi.fn(),
|
||||
toggleLink: vi.fn(),
|
||||
getSelectedText: () => "",
|
||||
isActive: () => false,
|
||||
subscribe: () => () => {},
|
||||
...overrides,
|
||||
setMarkdown: () => {},
|
||||
insertMarkdown: vi.fn(),
|
||||
scrollToCursor: () => {},
|
||||
selectAll: () => {},
|
||||
formatting: {
|
||||
run,
|
||||
getActiveFormats: () => activeFormats,
|
||||
getSelectedText: opts.getSelectedText ?? (() => ""),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
};
|
||||
return { controller, run };
|
||||
}
|
||||
|
||||
function renderToolbar(controller: Handle, onExit = vi.fn()) {
|
||||
const ref = createRef<Handle>();
|
||||
function renderToolbar(controller: EditorController, onExit = vi.fn()) {
|
||||
const ref = createRef<EditorController>();
|
||||
ref.current = controller;
|
||||
render(<FormattingToolbar controllerRef={ref} onExit={onExit} />);
|
||||
return { onExit };
|
||||
}
|
||||
|
||||
describe("FormattingToolbar", () => {
|
||||
it("invokes toggleBold when the bold button is clicked", () => {
|
||||
const controller = makeController();
|
||||
it("runs the bold command when the bold button is clicked", () => {
|
||||
const { controller, run } = makeController();
|
||||
renderToolbar(controller);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.format.bold" }));
|
||||
expect(controller.toggleBold).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("bold");
|
||||
});
|
||||
|
||||
it("invokes setHeading when a heading level is chosen", () => {
|
||||
const controller = makeController();
|
||||
it("runs the heading command when a heading level is chosen", () => {
|
||||
const { controller, run } = makeController();
|
||||
renderToolbar(controller);
|
||||
// Keyboard open is the most reliable path for Radix menus in jsdom.
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "editor.format.heading" }), { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "editor.format.heading-2" }));
|
||||
expect(controller.setHeading).toHaveBeenCalledWith(2);
|
||||
expect(run).toHaveBeenCalledWith("heading2");
|
||||
});
|
||||
|
||||
it("reflects active marks via aria-pressed", () => {
|
||||
const controller = makeController({ isActive: (name) => name === "bold" });
|
||||
const { controller } = makeController({ active: { bold: true } });
|
||||
renderToolbar(controller);
|
||||
expect(screen.getByRole("button", { name: "editor.format.bold" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "editor.format.italic" })).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("prompts for a URL and links the selection when adding a link", () => {
|
||||
const controller = makeController({ getSelectedText: () => "memos" });
|
||||
const { controller, run } = makeController({ getSelectedText: () => "memos" });
|
||||
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://usememos.com");
|
||||
renderToolbar(controller);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.format.link" }));
|
||||
expect(controller.toggleLink).toHaveBeenCalledWith("https://usememos.com");
|
||||
expect(run).toHaveBeenCalledWith("link", { url: "https://usememos.com" });
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("removes an active link without prompting", () => {
|
||||
const controller = makeController({ isActive: (name) => name === "link" });
|
||||
const { controller, run } = makeController({ active: { link: true } });
|
||||
const promptSpy = vi.spyOn(window, "prompt");
|
||||
renderToolbar(controller);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.format.link" }));
|
||||
expect(promptSpy).not.toHaveBeenCalled();
|
||||
expect(controller.toggleLink).toHaveBeenCalledWith();
|
||||
expect(run).toHaveBeenCalledWith("link");
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls onExit when the exit button is clicked", () => {
|
||||
const controller = makeController();
|
||||
const { controller } = makeController();
|
||||
const { onExit } = renderToolbar(controller);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.exit-focus-mode" }));
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
|
||||
import { EditorProvider, useEditorSelector } from "@/components/MemoEditor/state";
|
||||
import InsertMenu from "@/components/MemoEditor/Toolbar/InsertMenu";
|
||||
|
||||
// useTranslate returns the i18n key directly (no i18next backend in tests).
|
||||
@@ -20,8 +20,8 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
function ModeProbe() {
|
||||
const { state } = useEditorContext();
|
||||
return <span data-testid="mode">{state.ui.editorMode}</span>;
|
||||
const editorMode = useEditorSelector((s) => s.ui.editorMode);
|
||||
return <span data-testid="mode">{editorMode}</span>;
|
||||
}
|
||||
|
||||
function renderInsertMenu() {
|
||||
|
||||
@@ -34,36 +34,11 @@ describe("PlainEditor EditorController", () => {
|
||||
expect(textarea.value).toBe("first line\n\ntranscribed");
|
||||
});
|
||||
|
||||
it("toggleBold wraps the selection in **", () => {
|
||||
const { controller, textarea } = setup("read the docs");
|
||||
textarea.setSelectionRange(9, 13);
|
||||
controller.toggleBold();
|
||||
expect(textarea.value).toBe("read the **docs**");
|
||||
});
|
||||
|
||||
it("toggleTaskList prefixes and unprefixes the current line", () => {
|
||||
const { controller, textarea } = setup("buy milk");
|
||||
textarea.setSelectionRange(4, 4);
|
||||
controller.toggleTaskList();
|
||||
expect(textarea.value).toBe("- [ ] buy milk");
|
||||
controller.toggleTaskList();
|
||||
expect(textarea.value).toBe("buy milk");
|
||||
});
|
||||
|
||||
it("toggleTaskList unprefixes a checked task line", () => {
|
||||
const { controller, textarea } = setup("- [x] done thing");
|
||||
textarea.setSelectionRange(8, 8);
|
||||
controller.toggleTaskList();
|
||||
expect(textarea.value).toBe("done thing");
|
||||
});
|
||||
|
||||
it("toggleTaskList preserves indentation", () => {
|
||||
const { controller, textarea } = setup(" nested item");
|
||||
textarea.setSelectionRange(4, 4);
|
||||
controller.toggleTaskList();
|
||||
expect(textarea.value).toBe(" - [ ] nested item");
|
||||
controller.toggleTaskList();
|
||||
expect(textarea.value).toBe(" nested item");
|
||||
// The raw textarea is an honest fallback with no rich-formatting capability
|
||||
// (controller.formatting is undefined); the focus-mode toolbar is WYSIWYG-only.
|
||||
it("exposes no formatting capability", () => {
|
||||
const { controller } = setup("x");
|
||||
expect(controller.formatting).toBeUndefined();
|
||||
});
|
||||
|
||||
it("insertMarkdown with an empty string is a no-op", () => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoSave } from "@/components/MemoEditor/hooks/useAutoSave";
|
||||
import { cacheService } from "@/components/MemoEditor/services/cacheService";
|
||||
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
|
||||
|
||||
// Probe surfaces the store's dispatch/actions plus the autosave API so tests can
|
||||
// drive content changes the way the editor does and assert on cache writes.
|
||||
let api: {
|
||||
dispatch: ReturnType<typeof useEditorContext>["dispatch"];
|
||||
actions: ReturnType<typeof useEditorContext>["actions"];
|
||||
discardDraft: () => void;
|
||||
};
|
||||
|
||||
function Probe({ username, cacheKey, enabled }: { username: string; cacheKey?: string; enabled?: boolean }) {
|
||||
const { dispatch, actions } = useEditorContext();
|
||||
const { discardDraft } = useAutoSave(username, cacheKey, enabled);
|
||||
api = { dispatch, actions, discardDraft };
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useAutoSave (store-subscribed)", () => {
|
||||
let saveSpy: ReturnType<typeof vi.spyOn>;
|
||||
let saveNowSpy: ReturnType<typeof vi.spyOn>;
|
||||
let clearSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
saveSpy = vi.spyOn(cacheService, "save").mockImplementation(() => {});
|
||||
saveNowSpy = vi.spyOn(cacheService, "saveNow").mockImplementation(() => {});
|
||||
clearSpy = vi.spyOn(cacheService, "clear").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("persists content to the draft cache when content changes", () => {
|
||||
render(
|
||||
<EditorProvider>
|
||||
<Probe username="users/steven" cacheKey="k" enabled />
|
||||
</EditorProvider>,
|
||||
);
|
||||
const key = cacheService.key("users/steven", "k");
|
||||
saveSpy.mockClear(); // ignore the mount-time persist of the initial empty content
|
||||
act(() => {
|
||||
api.dispatch(api.actions.updateContent("hello world"));
|
||||
});
|
||||
expect(saveSpy).toHaveBeenCalledWith(key, "hello world");
|
||||
});
|
||||
|
||||
it("does not persist when disabled", () => {
|
||||
render(
|
||||
<EditorProvider>
|
||||
<Probe username="users/steven" cacheKey="k" enabled={false} />
|
||||
</EditorProvider>,
|
||||
);
|
||||
saveSpy.mockClear();
|
||||
act(() => {
|
||||
api.dispatch(api.actions.updateContent("ignored"));
|
||||
});
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discardDraft clears the cache and suppresses the unmount flush", () => {
|
||||
const { unmount } = render(
|
||||
<EditorProvider>
|
||||
<Probe username="users/steven" cacheKey="k2" enabled />
|
||||
</EditorProvider>,
|
||||
);
|
||||
const key = cacheService.key("users/steven", "k2");
|
||||
act(() => {
|
||||
api.dispatch(api.actions.updateContent("draft body"));
|
||||
});
|
||||
act(() => {
|
||||
api.discardDraft();
|
||||
});
|
||||
expect(clearSpy).toHaveBeenCalledWith(key);
|
||||
|
||||
saveNowSpy.mockClear();
|
||||
unmount();
|
||||
// The just-discarded content equals the latest content, so the unmount
|
||||
// flush must NOT re-persist it as a stale draft.
|
||||
expect(saveNowSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user