memos/web/tests/formatting-controller.test.tsx
johnnyjoygh deddf71d7b feat(editor): add focus-mode formatting toolbar
Add a rich-text formatting toolbar as the focus-mode header when the
WYSIWYG editor is active: heading dropdown, bold/italic/code, lists, and
link, with a priority+overflow responsive layout and live active-state
highlighting. The toolbar is a self-contained component driven through a
new FormattingController surface routed via EditorContent.

Remove the now-redundant slash-command feature (/todo, /code, /link,
/table) and its "Type / for commands" hint, since the toolbar covers
those actions; the shared suggestion renderer stays for #tag.
2026-06-21 11:57:45 +08:00

93 lines
3.4 KiB
TypeScript

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";
vi.mock("@/hooks/useUserQueries", async (importOriginal) => ({
...(await importOriginal<object>()),
useTagCounts: () => ({ data: {} }),
}));
type Handle = EditorController & FormattingController;
function setup(initialContent = "") {
const ref = createRef<Handle>();
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", () => {
const ref = setup("code me");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleCode());
expect(ref.current?.getMarkdown()).toBe("`code me`");
});
it("toggleBulletList converts the current block", () => {
const ref = setup("item");
act(() => ref.current?.toggleBulletList());
expect(ref.current?.getMarkdown()).toBe("- item");
});
it("toggleOrderedList converts the current block", () => {
const ref = setup("item");
act(() => ref.current?.toggleOrderedList());
expect(ref.current?.getMarkdown()).toBe("1. item");
});
it("setHeading makes the block a heading and isActive reflects the level", () => {
const ref = setup("title");
act(() => ref.current?.setHeading(2));
expect(ref.current?.getMarkdown()).toBe("## title");
expect(ref.current?.isActive("heading", { level: 2 })).toBe(true);
expect(ref.current?.isActive("heading", { level: 1 })).toBe(false);
});
it("setParagraph reverts a heading", () => {
const ref = setup("# title");
act(() => ref.current?.setParagraph());
expect(ref.current?.getMarkdown()).toBe("title");
});
it("isActive('bold') tracks the bold mark", () => {
const ref = setup("x");
expect(ref.current?.isActive("bold")).toBe(false);
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleBold());
expect(ref.current?.isActive("bold")).toBe(true);
});
it("toggleLink 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"));
expect(ref.current?.getMarkdown()).toBe("[memos](https://usememos.com)");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleLink());
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");
});
it("subscribe fires on transactions and unsubscribe stops it", () => {
const ref = setup("x");
const listener = vi.fn();
let unsubscribe = () => {};
act(() => {
unsubscribe = ref.current!.subscribe(listener);
});
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleBold());
const callsWhileSubscribed = listener.mock.calls.length;
expect(callsWhileSubscribed).toBeGreaterThan(0);
act(() => unsubscribe());
act(() => ref.current?.toggleItalic());
expect(listener.mock.calls.length).toBe(callsWhileSubscribed);
});
});