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.
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
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, useEditorSelector } from "@/components/MemoEditor/state";
|
|
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
|
|
|
const toastMock = vi.hoisted(() => vi.fn());
|
|
vi.mock("react-hot-toast", () => ({
|
|
default: Object.assign(toastMock, { error: vi.fn(), success: vi.fn() }),
|
|
toast: Object.assign(toastMock, { error: vi.fn(), success: vi.fn() }),
|
|
}));
|
|
|
|
const isLosslessMock = vi.hoisted(() => vi.fn());
|
|
vi.mock("@/components/MemoEditor/Editor/markdownCodec", () => ({
|
|
isLosslessRoundTrip: isLosslessMock,
|
|
}));
|
|
|
|
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 editorMode = useEditorSelector((s) => s.ui.editorMode);
|
|
return <span data-testid="mode">{editorMode}</span>;
|
|
}
|
|
|
|
function renderGuard(content: string) {
|
|
render(
|
|
<EditorProvider>
|
|
<Harness content={content} />
|
|
</EditorProvider>,
|
|
);
|
|
}
|
|
|
|
describe("memo-open load guard", () => {
|
|
it("switches the session to raw mode when the round trip would lose content", () => {
|
|
localStorage.clear();
|
|
isLosslessMock.mockReturnValue(false);
|
|
renderGuard("some exotic content");
|
|
expect(screen.getByTestId("mode").textContent).toBe("raw");
|
|
expect(toastMock).toHaveBeenCalled();
|
|
expect(isLosslessMock).toHaveBeenCalledWith("some exotic content");
|
|
// Session-only: the persisted preference is untouched.
|
|
expect(localStorage.getItem("memos-editor-mode")).toBeNull();
|
|
});
|
|
|
|
it("stays in wysiwyg when the round trip is lossless", () => {
|
|
localStorage.clear();
|
|
isLosslessMock.mockReturnValue(true);
|
|
renderGuard("plain content");
|
|
expect(screen.getByTestId("mode").textContent).toBe("wysiwyg");
|
|
});
|
|
|
|
it("skips the round-trip check when the user already prefers raw mode", () => {
|
|
localStorage.clear();
|
|
localStorage.setItem("memos-editor-mode", "raw");
|
|
isLosslessMock.mockReturnValue(false);
|
|
renderGuard("exotic content");
|
|
expect(isLosslessMock).not.toHaveBeenCalled();
|
|
expect(toastMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|