90 KiB
Markdown WYSIWYG Editor Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the plain <textarea> memo editor with a Tiptap-based WYSIWYG editor that renders markdown live while keeping markdown as the only storage format, with a per-device toggle back to the raw textarea editor.
Architecture: Both editors sit behind a shared EditorController interface hosted by EditorContent. The Tiptap editor uses the official @tiptap/markdown extension (Tiptap 3.26) for bidirectional markdown; custom Tag, PreservedBlock/PreservedInline, TagSuggestion, and SlashCommand extensions cover memos-specific features and fidelity. Unmodeled syntax (tables, math, raw HTML) is preserved byte-for-byte. A round-trip corpus test is written first as the go/no-go spike gate.
Tech Stack: React 19, Tiptap 3.26 (@tiptap/core, @tiptap/react, @tiptap/starter-kit, @tiptap/extension-list, @tiptap/extensions, @tiptap/suggestion, @tiptap/markdown + marked 17), vitest + jsdom + @testing-library/react, Tailwind 4.
Context for the implementer (read first)
- All web code lives in
web/. Run commands from/Users/steven/Projects/usememos/memos/webunless stated otherwise. Package manager is pnpm. - Tests live in
web/tests/*.test.{ts,tsx}(NOT colocated with src), run bypnpm test(vitest, jsdom, setup fileweb/tests/setup.ts). Path alias@/→web/src/. - Lint/typecheck:
pnpm lint(runstsc --noEmit+ biome). Auto-fix style:pnpm lint:fix. - The current textarea editor is
web/src/components/MemoEditor/Editor/index.tsx, exposing an imperativeEditorRefActionsAPI. It stays — it powers raw mode. Do not delete it or its sub-features (TagSuggestions.tsx,SlashCommands.tsx,useSuggestions.ts,useListCompletion.ts,shortcuts.ts,commands.ts). - Editor state flows through a reducer context (
web/src/components/MemoEditor/state/).state.contentis the markdown string; auto-save drafts (useAutoSave), validation, and save (memoService.save) all read it. The backend only ever sees markdown. - Verified API facts (Tiptap 3.26.0, inspected from published sources — do not re-derive):
@tiptap/markdownexportsMarkdown(an Extension). It addseditor.getMarkdown(): string,editor.markdown.parse(md): JSONContent, and acontentType: "markdown"option tonew Editor(),setContent,insertContent,insertContentAt.- Extensions declare markdown behavior via top-level config fields (typed in
@tiptap/core):markdownTokenName(marked token type to handle),parseMarkdown: (token, helpers) => JSONContent | JSONContent[] | null,renderMarkdown: (node, helpers, context) => string, andmarkdownTokenizer: { name, level: "inline" | "block", start, tokenize }(a marked tokenizer extension; custom tokenizers run before marked's built-ins). parseMarkdownhelpers includecreateNode(type, attrs?, content?),createTextNode(text, marks?),parseInline(tokens),parseChildren(tokens).- The
Documentextension joins top-level blocks with\n\n; block renderers return their content without trailing separators (e.g. CodeBlock returns```lang\n…\n```). - Nodes/marks with
code: trueskip the serializer's markdown escaping and HTML-entity encoding for their inner text — this is what makes verbatim preservation work. - Inline
htmltokens are handled internally by the markdown manager before the handler registry (recognized tags like<em>get converted to marks — a fidelity violation for us), so inline HTML preservation must use a custom inline tokenizer that captures tags before marked's built-in html tokenizer. Block-levelhtmltokens DO consult the registry first, so a plainparseMarkdownhandler works for them. - StarterKit v3 already includes: Bold, Italic, Strike, Code, CodeBlock, Heading, BulletList/OrderedList/ListItem/ListKeymap, Blockquote, Link (with
linkOnPaste: truedefault — URL-paste-over-selection → link), Underline, HardBreak, HorizontalRule, Dropcursor, Gapcursor, UndoRedo, TrailingNode. All ship with markdown input rules and parse/render specs. TaskList/TaskItemare exported from@tiptap/extension-list.Placeholderis exported from@tiptap/extensions.Suggestionis the default and a named export of@tiptap/suggestion.
- Deliberate deviation from the spec: the spec says serialized markdown flows to the reducer "debounced". We serialize on every
updateevent instead — serialization is cheap at memo scale, and synchronousstate.contentkeeps the save path, draft cache, and mode-toggle handoff race-free. Revisit only if profiling shows cost. - Conventional commits, matching repo style:
feat(web): …,fix(web): …,test(web): ….
File structure
New files:
| File | Responsibility |
|---|---|
web/src/components/MemoEditor/types/editorController.ts |
The shared EditorController interface |
web/src/components/MemoEditor/Editor/controllerAdapter.ts |
Adapts textarea EditorRefActions → EditorController |
web/src/components/MemoEditor/TiptapEditor/extensions.ts |
Canonical schema extension list (buildExtensions()) |
web/src/components/MemoEditor/TiptapEditor/markdownCodec.ts |
Headless parse/serialize/round-trip helpers (tests + load guard) |
web/src/components/MemoEditor/TiptapEditor/PreservedBlock.ts |
Fidelity workhorse: preserved block node, preserved inline mark, parse bridges for table/html/math |
web/src/components/MemoEditor/TiptapEditor/Tag.ts |
Inline #tag atom node with tokenizer + input rule |
web/src/components/MemoEditor/TiptapEditor/suggestionMenu.tsx |
Shared popup component + Suggestion-plugin renderer factory |
web/src/components/MemoEditor/TiptapEditor/TagSuggestion.ts |
# suggestion popup extension |
web/src/components/MemoEditor/TiptapEditor/SlashCommand.ts |
/ command popup extension |
web/src/components/MemoEditor/TiptapEditor/index.tsx |
The Tiptap editor React component (implements EditorController) |
web/src/components/MemoEditor/editorMode.ts |
localStorage mode preference helpers |
web/tests/fixtures/markdown-corpus/supported/*.md |
Round-trip fixtures: semantic equality group |
web/tests/fixtures/markdown-corpus/preserved/*.md |
Round-trip fixtures: byte equality group |
Modified files: web/src/components/MemoEditor/Editor/shortcuts.ts (export toggleTextStyle), state/types.ts + state/actions.ts + state/reducer.ts + state/context.tsx (editor mode), components/EditorContent.tsx (dual host), components/EditorToolbar.tsx (toggle button), hooks/useKeyboard.ts + hooks/useMemoInit.ts + index.tsx (switch to EditorController), web/src/locales/en.json (new strings), web/src/index.css (task-list/placeholder styles), web/package.json.
Task 1: Branch + dependencies
Files:
-
Modify:
web/package.json(via pnpm) -
Step 1: Create the feature branch (skip if already on an isolated worktree created for this plan)
cd /Users/steven/Projects/usememos/memos
git checkout -b feat/wysiwyg-editor
- Step 2: Install Tiptap packages
cd /Users/steven/Projects/usememos/memos/web
pnpm add @tiptap/core@3.26.0 @tiptap/pm@3.26.0 @tiptap/react@3.26.0 @tiptap/starter-kit@3.26.0 @tiptap/extension-list@3.26.0 @tiptap/extensions@3.26.0 @tiptap/suggestion@3.26.0 @tiptap/markdown@3.26.0 marked@^17.0.1
Expected: pnpm resolves and writes to package.json/pnpm-lock.yaml without peer-dependency errors (@tiptap/pm is the peer of @tiptap/markdown).
- Step 3: Verify the toolchain still passes
pnpm lint && pnpm test
Expected: PASS (no source changes yet).
- Step 4: Commit
git add package.json pnpm-lock.yaml
git commit -m "feat(web): add tiptap 3.26 dependencies for wysiwyg editor"
Task 2: Spike — markdown codec + round-trip corpus test (GO/NO-GO gate)
This is the spike from the spec's fidelity contract. It must be done first. If, after honest effort at configuration/custom tokenizers, the supported-syntax corpus cannot pass, STOP and report: the fallback decision (swap only the parse/serialize layer to remark, keeping the Tiptap editor) is a human decision, not something to improvise mid-plan.
Files:
-
Create:
web/src/components/MemoEditor/TiptapEditor/extensions.ts -
Create:
web/src/components/MemoEditor/TiptapEditor/markdownCodec.ts -
Create:
web/tests/fixtures/markdown-corpus/supported/(7 fixture files below) -
Test:
web/tests/markdown-roundtrip.test.ts -
Step 1: Create the corpus fixtures
web/tests/fixtures/markdown-corpus/supported/basic.md:
# Hello memos
Some **bold**, *italic*, ~~struck~~, and `inline code` text.
> A blockquote with **bold** inside.
A [link](https://example.com/docs) in a sentence.
web/tests/fixtures/markdown-corpus/supported/headings.md:
# H1
## H2
### H3
#### H4
##### H5
###### H6
Body under headings.
web/tests/fixtures/markdown-corpus/supported/lists.md:
- alpha
- beta
- nested one
- nested two
- gamma
1. first
2. second
1. second-a
2. second-b
3. third
web/tests/fixtures/markdown-corpus/supported/task-lists.md:
- [ ] open task
- [x] done task
- [ ] task with **bold** text
Regular paragraph between.
- [ ] another list
- [x] nested done
web/tests/fixtures/markdown-corpus/supported/code-blocks.md:
Some text first.
```go
func main() {
fmt.Println("hello *not italic* #not-a-tag")
}
```
```mermaid
graph TD;
A-->B;
```
```
no language fence
```
web/tests/fixtures/markdown-corpus/supported/cjk-emoji.md:
# 日本語の見出し
これは**太字**と*斜体*のテストです。改行も確認します。
- 中文列表项
- 한국어 항목
- emoji 🎉 in a list 🚀
> 引用文です 😀
web/tests/fixtures/markdown-corpus/supported/blockquote-mixed.md:
> Outer quote line one.
> Still the same quote.
Paragraph after.
> - a list in a quote
> - second item
- Step 2: Write the failing round-trip test
web/tests/markdown-roundtrip.test.ts:
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseMarkdown, roundTripMarkdown } from "@/components/MemoEditor/TiptapEditor/markdownCodec";
const CORPUS_DIR = join(__dirname, "fixtures", "markdown-corpus");
function fixtures(group: string): Array<[name: string, source: string]> {
const dir = join(CORPUS_DIR, group);
return readdirSync(dir)
.filter((f) => f.endsWith(".md"))
.sort()
.map((f) => [f, readFileSync(join(dir, f), "utf8")]);
}
// Fidelity contract part 1: supported constructs round-trip semantically.
// "Semantic" = the parsed document tree is identical before and after a
// serialize cycle; marker style (e.g. `*` vs `-` bullets) may normalize.
describe("markdown round-trip corpus: supported syntax (semantic equality)", () => {
for (const [name, source] of fixtures("supported")) {
it(`${name}: parse → serialize → parse is identity`, () => {
const serialized = roundTripMarkdown(source);
expect(parseMarkdown(serialized)).toEqual(parseMarkdown(source));
});
it(`${name}: serialization is idempotent`, () => {
const once = roundTripMarkdown(source);
expect(roundTripMarkdown(once)).toBe(once);
});
}
});
- Step 3: Run it to verify it fails for the right reason
pnpm test -- markdown-roundtrip
Expected: FAIL with "Failed to resolve import …/markdownCodec" (module doesn't exist yet).
- Step 4: Implement the extension list and the codec
web/src/components/MemoEditor/TiptapEditor/extensions.ts:
import type { AnyExtension } from "@tiptap/core";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Markdown } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
/**
* The canonical schema-relevant extension set, shared by the live editor and
* the headless markdown codec so that parse/serialize behavior is identical
* in both. UI-only extensions (Placeholder, suggestion popups) are added by
* the editor component on top of this list and must never affect the schema.
*/
export function buildExtensions(): AnyExtension[] {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
link: { openOnClick: false },
// Markdown has no underline syntax; keeping the extension would let
// Ctrl+U create marks that cannot serialize. Out of the schema entirely.
underline: false,
}),
TaskList,
TaskItem.configure({ nested: true }),
Markdown,
];
}
web/src/components/MemoEditor/TiptapEditor/markdownCodec.ts:
import { Editor, type JSONContent } from "@tiptap/core";
import { buildExtensions } from "./extensions";
/**
* Headless markdown ⇄ ProseMirror document helpers built on the exact same
* extension list as the live editor. Used by the round-trip corpus tests and
* the memo-open load guard.
*/
function withEditor<T>(markdown: string, fn: (editor: Editor) => T): T {
const editor = new Editor({
extensions: buildExtensions(),
content: markdown,
contentType: "markdown",
});
try {
return fn(editor);
} finally {
editor.destroy();
}
}
export function parseMarkdown(markdown: string): JSONContent {
return withEditor(markdown, (editor) => editor.getJSON());
}
export function roundTripMarkdown(markdown: string): string {
return withEditor(markdown, (editor) => editor.getMarkdown());
}
/** True when a serialize cycle would not change the document's meaning. */
export function isLosslessRoundTrip(markdown: string): boolean {
try {
const once = parseMarkdown(markdown);
const twice = parseMarkdown(roundTripMarkdown(markdown));
return JSON.stringify(once) === JSON.stringify(twice);
} catch {
return false;
}
}
- Step 5: Run the corpus test — this is the gate
pnpm test -- markdown-roundtrip
Expected: PASS (14 tests: 7 fixtures × 2 assertions).
Troubleshooting, in order:
new Editor()throws about a missing element in jsdom → passelement: document.createElement("div")inwithEditor.- A specific fixture fails semantic equality → diff the two JSON trees (
console.log(JSON.stringify(parseMarkdown(src), null, 2))), then fix via StarterKit configuration or (only if config can't) a small customparseMarkdown/renderMarkdownoverride on the offending extension via.extend()inextensions.ts. Document each override with a comment naming the fixture that needs it. - Idempotence fails but semantic equality passes → acceptable only if the second pass output equals the third (stable after one normalization); in that case change the idempotence assertion to compare pass 2 vs pass 3 and leave a comment explaining the one-time normalization. Otherwise treat as a real bug.
- If supported-syntax failures cannot be fixed at all → STOP. Report NO-GO with the failing fixtures and diffs. Do not proceed to Task 3.
- Step 6: Commit
cd /Users/steven/Projects/usememos/memos
git add web/src/components/MemoEditor/TiptapEditor web/tests
git commit -m "test(web): add markdown round-trip corpus spike for tiptap codec"
Task 3: PreservedBlock / PreservedInline — byte-for-byte fidelity for unmodeled syntax
Tables, $…$/$$…$$ math, and raw HTML are out of the v1 editing scope but must survive an edit session byte-for-byte. Block constructs become an editable literal-text block node (preservedBlock, mono styling, code: true so nothing inside is escaped); inline constructs become plain text carrying a preservedInline mark (code: true likewise). Marked "bridge" extensions route the relevant tokens into them at parse time.
Files:
-
Create:
web/src/components/MemoEditor/TiptapEditor/PreservedBlock.ts -
Modify:
web/src/components/MemoEditor/TiptapEditor/extensions.ts -
Create:
web/tests/fixtures/markdown-corpus/preserved/(5 fixtures) -
Test:
web/tests/tiptap-preserved.test.ts, extendweb/tests/markdown-roundtrip.test.ts -
Step 1: Create the preserved-corpus fixtures
IMPORTANT authoring constraint: these files are asserted byte-equal (trimmed) after a round trip, so they must contain only preserved constructs plus plain prose that no serializer pass would rewrite — no *, _, `, [, ], ~, \ characters outside the preserved spans, no list markers, no headings.
web/tests/fixtures/markdown-corpus/preserved/table.md:
| Name | Role |
| --- | --- |
| Ada | Engineer |
| Grace | Admiral |
web/tests/fixtures/markdown-corpus/preserved/math-block.md:
Before the math.
$$
E = mc^2 \cdot \frac{1}{\sqrt{1 - v^2/c^2}}
$$
After the math.
web/tests/fixtures/markdown-corpus/preserved/math-inline.md:
Energy is $E=mc^2$ and the index is $x_1$ in this sentence.
web/tests/fixtures/markdown-corpus/preserved/inline-html.md:
Use <em>emphasis</em> and a line<br>break and <span class="x">spans</span> here.
web/tests/fixtures/markdown-corpus/preserved/block-html.md:
Paragraph before.
<div class="custom">
<p>Raw HTML block content</p>
</div>
Paragraph after.
- Step 2: Extend the corpus test with the byte-equality group
Append to web/tests/markdown-roundtrip.test.ts:
// Fidelity contract part 2: unmodeled constructs round-trip byte-for-byte.
// Fixtures in preserved/ contain only preserved constructs + inert prose,
// so the entire file must survive a round trip unchanged (modulo outer trim).
describe("markdown round-trip corpus: preserved syntax (byte equality)", () => {
for (const [name, source] of fixtures("preserved")) {
it(`${name}: round-trips byte-for-byte`, () => {
expect(roundTripMarkdown(source).trim()).toBe(source.trim());
});
it(`${name}: serialization is idempotent`, () => {
const once = roundTripMarkdown(source);
expect(roundTripMarkdown(once)).toBe(once);
});
}
});
- Step 3: Write the unit test for the preservation nodes
web/tests/tiptap-preserved.test.ts:
import { describe, expect, it } from "vitest";
import { parseMarkdown, roundTripMarkdown } from "@/components/MemoEditor/TiptapEditor/markdownCodec";
function blockTypes(markdown: string): string[] {
return (parseMarkdown(markdown).content ?? []).map((node) => node.type ?? "");
}
describe("PreservedBlock", () => {
it("captures a table as a single preservedBlock node", () => {
const md = "| a | b |\n| --- | --- |\n| 1 | 2 |";
expect(blockTypes(md)).toEqual(["preservedBlock"]);
});
it("captures block math as preservedBlock", () => {
const md = "$$\nx^2\n$$";
expect(blockTypes(md)).toEqual(["preservedBlock"]);
});
it("captures block HTML as preservedBlock", () => {
const md = "<div>\nhello\n</div>";
expect(blockTypes(md)).toEqual(["preservedBlock"]);
});
});
describe("PreservedInline", () => {
it("keeps inline math verbatim — underscores must not get escaped", () => {
expect(roundTripMarkdown("index $x_1$ here").trim()).toBe("index $x_1$ here");
});
it("keeps inline HTML tags literal instead of converting them to marks", () => {
expect(roundTripMarkdown("an <em>emphasis</em> tag").trim()).toBe("an <em>emphasis</em> tag");
});
it("does not swallow autolinks", () => {
const doc = parseMarkdown("see <https://example.com> now");
const para = doc.content?.[0];
const hasLinkMark = (para?.content ?? []).some((n) => (n.marks ?? []).some((m) => m.type === "link"));
expect(hasLinkMark).toBe(true);
});
});
- Step 4: Run both tests to verify they fail
pnpm test -- tiptap-preserved markdown-roundtrip
Expected: the new preserved/byte-equality and unit tests FAIL (e.g. table parsed via fallback, $x_1$ re-serialized as $x\_1$); the supported group still PASSES.
- Step 5: Implement the preservation extensions
web/src/components/MemoEditor/TiptapEditor/PreservedBlock.ts:
import { Extension, Mark, Node, mergeAttributes } from "@tiptap/core";
import type { MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
/**
* Fidelity workhorse for syntax the WYSIWYG editor does not model (tables,
* math, raw HTML). Constructs are captured at parse time with their raw
* markdown source, shown as editable literal mono text, and re-emitted
* byte-for-byte on serialize. `code: true` keeps the serializer from
* escaping/entity-encoding the inner text.
*/
export const PreservedBlock = Node.create({
name: "preservedBlock",
group: "block",
content: "text*",
marks: "",
code: true,
defining: true,
parseHTML() {
return [{ tag: "pre[data-preserved-block]", preserveWhitespace: "full" }];
},
renderHTML({ HTMLAttributes }) {
return [
"pre",
mergeAttributes(HTMLAttributes, {
"data-preserved-block": "",
class: "font-mono text-sm opacity-80 whitespace-pre-wrap my-0",
}),
0,
];
},
// The literal source is the node's text; Document joins blocks with \n\n.
renderMarkdown: (node, helpers) => (node.content ? helpers.renderChildren(node.content) : ""),
});
/** Inline counterpart: a `code: true` mark that emits its text verbatim. */
export const PreservedInline = Mark.create({
name: "preservedInline",
code: true,
parseHTML() {
return [{ tag: "span[data-preserved-inline]" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-preserved-inline": "", class: "font-mono opacity-80" }), 0];
},
// No delimiters: the preserved text already contains its own syntax.
renderMarkdown: (node, helpers) => (node.content ? helpers.renderChildren(node.content) : ""),
});
function preservedBlockFromToken(token: MarkdownToken, helpers: MarkdownParseHelpers) {
const source = (token.raw ?? "").replace(/\n+$/, "");
if (!source) {
return null;
}
return helpers.createNode("preservedBlock", {}, [helpers.createTextNode(source)]);
}
/** Routes marked's `table` tokens into preservedBlock instead of dropping them. */
export const PreservedTableBridge = Extension.create({
name: "preservedTableBridge",
markdownTokenName: "table",
parseMarkdown: preservedBlockFromToken,
});
/**
* Routes block-level raw HTML into preservedBlock. Inline HTML never reaches
* the handler registry (the markdown manager intercepts it and would convert
* recognized tags like <em> into marks), so inline tags are captured earlier
* by the custom tokenizer in PreservedHtmlInlineBridge below.
*/
export const PreservedHtmlBlockBridge = Extension.create({
name: "preservedHtmlBlockBridge",
markdownTokenName: "html",
parseMarkdown: (token, helpers) => {
if (!token.block) {
return null;
}
return preservedBlockFromToken(token, helpers);
},
});
export const PreservedHtmlInlineBridge = Extension.create({
name: "preservedHtmlInlineBridge",
markdownTokenName: "preservedHtmlInline",
markdownTokenizer: {
name: "preservedHtmlInline",
level: "inline",
start: (src: string) => src.indexOf("<"),
tokenize(src: string) {
// One opening/closing/self-closing HTML tag. The first character after
// `<` (or `</`) must be a letter and the tag name only [a-zA-Z0-9-],
// followed by attributes/`>` — this never matches autolinks such as
// <https://example.com> or <mail@example.com> (`:`/`@` break the match).
const match = /^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s[^<>]*)?\/?>/.exec(src);
if (!match) {
return undefined;
}
return { type: "preservedHtmlInline", raw: match[0], text: match[0] };
},
},
parseMarkdown: (token, helpers) => helpers.createTextNode(token.raw ?? "", [{ type: "preservedInline" }]),
});
export const PreservedMathBlockBridge = Extension.create({
name: "preservedMathBlockBridge",
markdownTokenName: "preservedMathBlock",
markdownTokenizer: {
name: "preservedMathBlock",
level: "block",
start: (src: string) => src.indexOf("$$"),
tokenize(src: string) {
const match = /^\$\$[\s\S]+?\$\$(?:\n+|$)/.exec(src);
if (!match) {
return undefined;
}
return { type: "preservedMathBlock", raw: match[0], text: match[0] };
},
},
parseMarkdown: preservedBlockFromToken,
});
export const PreservedMathInlineBridge = Extension.create({
name: "preservedMathInlineBridge",
markdownTokenName: "preservedMathInline",
markdownTokenizer: {
name: "preservedMathInline",
level: "inline",
start: (src: string) => src.indexOf("$"),
tokenize(src: string) {
const match = /^\$[^$\n]+\$/.exec(src);
if (!match) {
return undefined;
}
return { type: "preservedMathInline", raw: match[0], text: match[0] };
},
},
parseMarkdown: (token, helpers) => helpers.createTextNode(token.raw ?? "", [{ type: "preservedInline" }]),
});
export const preservedExtensions = [
PreservedBlock,
PreservedInline,
PreservedTableBridge,
PreservedHtmlBlockBridge,
PreservedHtmlInlineBridge,
PreservedMathBlockBridge,
PreservedMathInlineBridge,
];
Register in web/src/components/MemoEditor/TiptapEditor/extensions.ts — add the import and spread the list into the returned array after Markdown:
import { preservedExtensions } from "./PreservedBlock";
// …inside buildExtensions() return array, after Markdown:
...preservedExtensions,
- Step 6: Run the tests until green
pnpm test -- tiptap-preserved markdown-roundtrip
Expected: PASS (all groups).
Troubleshooting:
-
TS error on
markdownTokenName/parseMarkdownconfig fields → confirm@tiptap/coreis 3.26.0 (pnpm why @tiptap/core); these fields are typed inExtendableConfigthere. -
Inline HTML still converted to
<em>marks → the custom tokenizer isn't winning; verify themarkdownTokenizer.namematchesmarkdownTokenNameexactly and check token output witheditor.markdown.parse("…")in a scratch test. -
block-html.mdbyte test fails on internal blank lines: marked may split an HTML block at blank lines into multiple tokens. If so, simplify the fixture's<div>to have no blank lines inside (the contract is per-construct preservation, not blank-line geometry inside raw HTML), and note it in the fixture. -
Tokenizer with
level: "block"not firing mid-document → marked requires block tokenizers to match at line starts; thestartindex approach is handled by the markdown manager. Verify with a one-line scratch test before deeper debugging. -
Step 7: Commit
git add web/src/components/MemoEditor/TiptapEditor web/tests
git commit -m "feat(web): preserve unmodeled markdown syntax byte-for-byte in tiptap editor"
Task 4: Tag extension — memos #tags as inline tokens
Files:
-
Create:
web/src/components/MemoEditor/TiptapEditor/Tag.ts -
Modify:
web/src/components/MemoEditor/TiptapEditor/extensions.ts -
Test:
web/tests/tiptap-tag.test.ts -
Step 1: Write the failing test
web/tests/tiptap-tag.test.ts:
import { describe, expect, it } from "vitest";
import { parseMarkdown, roundTripMarkdown } from "@/components/MemoEditor/TiptapEditor/markdownCodec";
function firstParagraphChildren(markdown: string) {
return parseMarkdown(markdown).content?.[0]?.content ?? [];
}
describe("Tag node", () => {
it("parses #tag into a tag node", () => {
const children = firstParagraphChildren("#hello world");
expect(children[0]).toMatchObject({ type: "tag", attrs: { tag: "hello" } });
expect(children[1]).toMatchObject({ type: "text", text: " world" });
});
it("serializes a tag node back to #tag verbatim", () => {
expect(roundTripMarkdown("a #b-tag c").trim()).toBe("a #b-tag c");
});
it("supports unicode and nested-path tags", () => {
expect(firstParagraphChildren("#日本語 and #work/project-1")[0]).toMatchObject({
type: "tag",
attrs: { tag: "日本語" },
});
const children = firstParagraphChildren("see #work/project-1 now");
const tagNode = children.find((n) => n.type === "tag");
expect(tagNode?.attrs?.tag).toBe("work/project-1");
});
it("does not turn headings into tags", () => {
expect(parseMarkdown("# heading").content?.[0]?.type).toBe("heading");
});
it("does not match a bare # followed by space", () => {
const children = firstParagraphChildren("a # b");
expect(children.every((n) => n.type === "text")).toBe(true);
});
});
- Step 2: Run it to verify it fails
pnpm test -- tiptap-tag
Expected: FAIL — #hello currently parses as plain text, no tag node.
- Step 3: Implement the Tag node
web/src/components/MemoEditor/TiptapEditor/Tag.ts:
import { InputRule, Node, mergeAttributes } from "@tiptap/core";
import type { MarkdownToken } from "@tiptap/core";
// Mirrors the renderer's tag lexer (web/src/utils/remark-plugins/remark-tag.ts):
// letters, numbers, symbols, plus _ - / &, max 100 chars. Keep the two in sync.
const TAG_CHAR = "[\\p{L}\\p{N}\\p{S}_\\-/&]";
const TAG_INPUT_RULE = new RegExp(`(?:^|\\s)#(${TAG_CHAR}{1,100})\\s$`, "u");
const TAG_TOKEN_RULE = new RegExp(`^#(${TAG_CHAR}{1,100})`, "u");
/**
* Inline atom for memos `#tags`: rendered as a styled token in the editor,
* serialized back to `#tag` verbatim. Parsed live while typing (input rule)
* and from markdown (custom marked tokenizer).
*/
export const Tag = Node.create({
name: "tag",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return { tag: { default: "" } };
},
parseHTML() {
return [
{
tag: "span[data-tag]",
getAttrs: (element) => ({ tag: (element as HTMLElement).getAttribute("data-tag") ?? "" }),
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-tag": node.attrs.tag,
class: "inline-block w-auto rounded bg-accent text-accent-foreground px-1",
}),
`#${node.attrs.tag}`,
];
},
addInputRules() {
return [
new InputRule({
find: TAG_INPUT_RULE,
handler: ({ state, range, match }) => {
// The match may include a leading space/start anchor — only replace
// from the `#` so we don't eat the separator before the tag.
const start = range.from + match[0].indexOf("#");
state.tr.replaceWith(start, range.to, [this.type.create({ tag: match[1] }), state.schema.text(" ")]);
},
}),
];
},
markdownTokenName: "memoTag",
markdownTokenizer: {
name: "memoTag",
level: "inline",
start: (src: string) => src.indexOf("#"),
tokenize(src: string) {
const match = TAG_TOKEN_RULE.exec(src);
if (!match) {
return undefined;
}
// Parity with remark-tag: `##x` is not a tag boundary.
if (src[match[0].length] === "#") {
return undefined;
}
return { type: "memoTag", raw: match[0], text: match[0], tag: match[1] };
},
},
parseMarkdown: (token, helpers) => helpers.createNode("tag", { tag: (token as MarkdownToken & { tag?: string }).tag ?? "" }),
renderMarkdown: (node) => `#${node.attrs?.tag ?? ""}`,
});
Register in extensions.ts (inside the buildExtensions() array, after the preserved extensions):
import { Tag } from "./Tag";
// …
Tag,
- Step 4: Run the tag tests and the full corpus
pnpm test -- tiptap-tag markdown-roundtrip tiptap-preserved
Expected: PASS. (The corpus must stay green — the # tokenizer must not affect headings, which marked tokenizes at block level before inline rules run.)
- Step 5: Commit
git add web/src/components/MemoEditor/TiptapEditor web/tests/tiptap-tag.test.ts
git commit -m "feat(web): add memos tag node to tiptap editor with verbatim serialization"
Task 5: EditorController interface + textarea adapter + MemoEditor rewiring
Introduce the shared interface and make the existing textarea editor implement it, so every consumer in MemoEditor stops depending on EditorRefActions. No Tiptap in the UI yet; behavior must be unchanged.
Files:
-
Create:
web/src/components/MemoEditor/types/editorController.ts -
Create:
web/src/components/MemoEditor/Editor/controllerAdapter.ts -
Modify:
web/src/components/MemoEditor/Editor/shortcuts.ts(exporttoggleTextStyle) -
Modify:
web/src/components/MemoEditor/components/EditorContent.tsx -
Modify:
web/src/components/MemoEditor/hooks/useKeyboard.ts -
Modify:
web/src/components/MemoEditor/hooks/useMemoInit.ts -
Modify:
web/src/components/MemoEditor/index.tsx -
Test:
web/tests/editor-controller-adapter.test.tsx -
Step 1: Define the interface
web/src/components/MemoEditor/types/editorController.ts:
/**
* The contract both memo editors (raw textarea and Tiptap WYSIWYG) implement.
* Everything outside an editor implementation must talk markdown through this
* interface and never reach for editor-specific APIs like EditorRefActions.
*/
export interface EditorController {
focus(): void;
hasFocus(): boolean;
isEmpty(): boolean;
/** Current document as a markdown string (the only storage format). */
getMarkdown(): string;
/** Replace the whole document from a markdown string. */
setMarkdown(markdown: string): void;
/** Insert markdown at the cursor as its own block, with blank-line separation as needed. */
insertMarkdown(markdown: string): void;
scrollToCursor(): void;
// Formatting intents — each editor realizes them natively.
toggleBold(): void;
toggleItalic(): void;
toggleTaskList(): void;
}
- Step 2: Export
toggleTextStylefrom shortcuts.ts
In web/src/components/MemoEditor/Editor/shortcuts.ts change the declaration on line 43 from:
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
to:
export function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
- Step 3: Write the failing adapter test
web/tests/editor-controller-adapter.test.tsx:
import { render, screen } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import Editor, { type EditorRefActions } from "@/components/MemoEditor/Editor";
import { createTextareaController } from "@/components/MemoEditor/Editor/controllerAdapter";
vi.mock("@/components/MemoEditor/Editor/TagSuggestions", () => ({ default: () => null }));
vi.mock("@/components/MemoEditor/Editor/SlashCommands", () => ({ default: () => null }));
function setup(initialContent = "") {
const ref = createRef<EditorRefActions>();
render(
<Editor
ref={ref}
className=""
initialContent={initialContent}
placeholder="memo"
onContentChange={vi.fn()}
onPaste={vi.fn()}
/>,
);
const controller = createTextareaController(() => ref.current);
const textarea = screen.getByPlaceholderText("memo") as HTMLTextAreaElement;
return { controller, textarea };
}
describe("textarea EditorController adapter", () => {
it("getMarkdown/setMarkdown mirror the textarea value", () => {
const { controller, textarea } = setup("hello");
expect(controller.getMarkdown()).toBe("hello");
controller.setMarkdown("changed");
expect(textarea.value).toBe("changed");
});
it("isEmpty treats whitespace-only content as empty", () => {
const { controller } = setup(" \n ");
expect(controller.isEmpty()).toBe(true);
});
it("insertMarkdown separates blocks with blank lines", () => {
const { controller, textarea } = setup("first line");
textarea.setSelectionRange(10, 10);
controller.insertMarkdown("transcribed");
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("hasFocus reflects the active element", () => {
const { controller, textarea } = setup("x");
expect(controller.hasFocus()).toBe(false);
textarea.focus();
expect(controller.hasFocus()).toBe(true);
});
});
- Step 4: Run it to verify it fails
pnpm test -- editor-controller-adapter
Expected: FAIL — controllerAdapter module not found.
- Step 5: Implement the adapter
web/src/components/MemoEditor/Editor/controllerAdapter.ts:
import type { EditorController } from "../types/editorController";
import { toggleTextStyle } from "./shortcuts";
import type { EditorRefActions } from "./index";
const TASK_PREFIX = "- [ ] ";
/**
* Adapts the textarea editor's imperative string-surgery API to the shared
* EditorController contract. Takes a getter because the underlying ref is
* populated after mount and may swap when the editor remounts.
*/
export function createTextareaController(getActions: () => EditorRefActions | null): EditorController {
return {
focus: () => getActions()?.focus(),
hasFocus: () => {
const element = getActions()?.getEditor();
return Boolean(element) && document.activeElement === element;
},
isEmpty: () => (getActions()?.getContent() ?? "").trim() === "",
getMarkdown: () => getActions()?.getContent() ?? "",
setMarkdown: (markdown) => getActions()?.setContent(markdown),
insertMarkdown: (markdown) => {
const actions = getActions();
if (!actions) return;
const content = actions.getContent();
const cursor = actions.getCursorPosition();
const before = content.slice(0, cursor);
const after = content.slice(cursor);
const prefix = before.length === 0 || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
const suffix = after.length === 0 || after.startsWith("\n\n") ? "" : after.startsWith("\n") ? "\n" : "\n\n";
actions.insertText(markdown, prefix, suffix);
},
scrollToCursor: () => getActions()?.scrollToCursor(),
toggleBold: () => {
const actions = getActions();
if (actions) toggleTextStyle(actions, "**");
},
toggleItalic: () => {
const actions = getActions();
if (actions) toggleTextStyle(actions, "*");
},
toggleTaskList: () => {
const actions = getActions();
if (!actions) return;
const lineNumber = actions.getCursorLineNumber();
const line = actions.getLine(lineNumber);
actions.setLine(lineNumber, line.startsWith(TASK_PREFIX) ? line.slice(TASK_PREFIX.length) : `${TASK_PREFIX}${line}`);
},
};
}
- Step 6: Run the adapter test
pnpm test -- editor-controller-adapter
Expected: PASS.
- Step 7: Rewire EditorContent to expose EditorController
Replace web/src/components/MemoEditor/components/EditorContent.tsx imports and component shell (the drag/paste/composition handlers stay exactly as they are):
import { forwardRef, useImperativeHandle, useRef } from "react";
import Editor, { type EditorRefActions } from "../Editor";
import { createTextareaController } from "../Editor/controllerAdapter";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useEditorContext } from "../state";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
import type { EditorController } from "../types/editorController";
export const EditorContent = forwardRef<EditorController, EditorContentProps>(({ placeholder }, ref) => {
const { state, actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
const textareaActionsRef = useRef<EditorRefActions>(null);
useImperativeHandle(ref, () => createTextareaController(() => textareaActionsRef.current), []);
// …existing dragHandlers / handleCompositionStart / handleCompositionEnd /
// handleContentChange / handlePaste bodies unchanged…
return (
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
<Editor
ref={textareaActionsRef}
className="memo-editor-content"
initialContent={state.content}
placeholder={placeholder || ""}
isFocusMode={state.ui.isFocusMode}
isInIME={state.ui.isComposing}
onContentChange={handleContentChange}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
</div>
);
});
EditorContent.displayName = "EditorContent";
- Step 8: Switch the MemoEditor consumers to the controller
In web/src/components/MemoEditor/hooks/useKeyboard.ts, replace the whole file:
import { useEffect, useRef } from "react";
import type { EditorController } from "../types/editorController";
export const useKeyboard = (editorRef: React.RefObject<EditorController | null>, onSave: () => void) => {
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") {
return;
}
if (!editorRef.current?.hasFocus()) {
return;
}
event.preventDefault();
onSaveRef.current();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editorRef]);
};
In web/src/components/MemoEditor/hooks/useMemoInit.ts, change only the type import/usage:
// remove: import type { EditorRefActions } from "../Editor";
import type { EditorController } from "../types/editorController";
interface UseMemoInitOptions {
editorRef: React.RefObject<EditorController | null>;
// …rest unchanged
}
In web/src/components/MemoEditor/index.tsx:
// remove: import type { EditorRefActions } from "./Editor";
import type { EditorController } from "./types/editorController";
// …
const editorRef = useRef<EditorController>(null);
and replace the insertTranscribedText callback body with:
const insertTranscribedText = useCallback((text: string) => {
const editor = editorRef.current;
if (!editor) {
return;
}
editor.insertMarkdown(text);
editor.scrollToCursor();
}, []);
- Step 9: Run the full suite + lint
pnpm lint && pnpm test
Expected: PASS — including the untouched memo-editor-shortcuts and memo-editor-paste tests (they render Editor directly and are unaffected).
- Step 10: Commit
git add web/src web/tests/editor-controller-adapter.test.tsx
git commit -m "refactor(web): introduce EditorController abstraction over the textarea editor"
Task 6: TiptapEditor component
The WYSIWYG editor component: useEditor with the Task-2 extension list + Placeholder, markdown in/out on every update, file-paste delegation, composition events, and a forwardRef EditorController.
Files:
-
Create:
web/src/components/MemoEditor/TiptapEditor/index.tsx -
Modify:
web/src/index.css(task-list + placeholder styles) -
Test:
web/tests/tiptap-editor-controller.test.tsx -
Step 1: Extend EditorController with
selectAll(needed to test selection-based intents)
Add to the EditorController interface in web/src/components/MemoEditor/types/editorController.ts (after scrollToCursor):
/** Select the entire document (used by tests and select-all flows). */
selectAll(): void;
and add the textarea implementation in web/src/components/MemoEditor/Editor/controllerAdapter.ts (after scrollToCursor):
selectAll: () => {
const actions = getActions();
if (!actions) return;
actions.setCursorPosition(0, actions.getContent().length);
},
- Step 2: Write the failing test
web/tests/tiptap-editor-controller.test.tsx:
import { act, render } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import TiptapEditor from "@/components/MemoEditor/TiptapEditor";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
function setup(initialContent = "") {
const ref = createRef<EditorController>();
const onContentChange = vi.fn();
render(
<TiptapEditor
initialContent={initialContent}
placeholder="memo"
onContentChange={onContentChange}
onPaste={vi.fn()}
/>,
);
return { ref, onContentChange };
}
describe("TiptapEditor EditorController", () => {
it("loads markdown and serializes it back", () => {
const { ref } = setup("# Title\n\nSome **bold** text.");
expect(ref.current?.getMarkdown()).toBe("# Title\n\nSome **bold** text.");
});
it("reports emptiness", () => {
const { ref } = setup("");
expect(ref.current?.isEmpty()).toBe(true);
});
it("setMarkdown replaces the document", () => {
const { ref } = setup("old");
act(() => ref.current?.setMarkdown("- [ ] task"));
expect(ref.current?.getMarkdown()).toBe("- [ ] task");
});
it("insertMarkdown adds content and notifies onContentChange", () => {
const { ref, onContentChange } = setup("");
act(() => ref.current?.insertMarkdown("hello"));
expect(ref.current?.getMarkdown()).toContain("hello");
expect(onContentChange).toHaveBeenCalledWith(expect.stringContaining("hello"));
});
it("toggleBold bolds the selected text", () => {
const { ref } = setup("bold me");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleBold());
expect(ref.current?.getMarkdown()).toBe("**bold me**");
});
it("toggleTaskList converts the current block", () => {
const { ref } = setup("buy milk");
act(() => ref.current?.toggleTaskList());
expect(ref.current?.getMarkdown()).toBe("- [ ] buy milk");
});
});
- Step 3: Run it to verify it fails
pnpm test -- tiptap-editor-controller
Expected: FAIL — @/components/MemoEditor/TiptapEditor has no index.tsx default export yet.
- Step 4: Implement the component
web/src/components/MemoEditor/TiptapEditor/index.tsx:
import { EditorContent as TiptapEditorContent, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extensions";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants";
import type { EditorController } from "../types/editorController";
import { buildExtensions } from "./extensions";
export interface TiptapEditorProps {
className?: string;
initialContent: string;
placeholder: string;
isFocusMode?: boolean;
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
}
/**
* WYSIWYG memo editor on Tiptap/ProseMirror. Markdown is the only format
* crossing its boundary: in via setContent(contentType: "markdown"), out via
* getMarkdown() on every update. IME, list continuation, input rules, and
* auto-grow are native ProseMirror/contenteditable behavior.
*/
const TiptapEditor = forwardRef<EditorController, TiptapEditorProps>(function TiptapEditor(props, ref) {
const { className, initialContent, placeholder, isFocusMode, onContentChange, onPaste, onCompositionStart, onCompositionEnd } = props;
const editor = useEditor({
extensions: [...buildExtensions(), Placeholder.configure({ placeholder })],
content: initialContent,
contentType: "markdown",
editorProps: {
attributes: {
class: "memo-wysiwyg outline-none w-full text-base break-words min-h-6",
},
handlePaste: (_view, event) => {
const hasFiles = Array.from(event.clipboardData?.items ?? []).some((item) => item.kind === "file");
if (hasFiles) {
onPaste(event as unknown as React.ClipboardEvent);
return true;
}
// Text paste (incl. URL-over-selection → link) is handled natively.
return false;
},
handleDOMEvents: {
compositionstart: () => {
onCompositionStart?.();
return false;
},
compositionend: () => {
onCompositionEnd?.();
return false;
},
},
},
onUpdate: ({ editor: currentEditor }) => {
onContentChange(currentEditor.getMarkdown());
},
});
// Sync external content changes (e.g. reset after save, draft restore)
// without clobbering the document the user is typing into: only apply when
// the markdown actually differs.
useEffect(() => {
if (!editor) {
return;
}
if (editor.getMarkdown() !== initialContent) {
editor.commands.setContent(initialContent, { contentType: "markdown", emitUpdate: false });
}
}, [initialContent, editor]);
useImperativeHandle(
ref,
(): EditorController => ({
focus: () => editor?.commands.focus(),
hasFocus: () => editor?.isFocused ?? false,
isEmpty: () => editor?.isEmpty ?? true,
getMarkdown: () => editor?.getMarkdown() ?? "",
setMarkdown: (markdown) => editor?.commands.setContent(markdown, { contentType: "markdown" }),
insertMarkdown: (markdown) => editor?.chain().focus().insertContent(markdown, { contentType: "markdown" }).run(),
scrollToCursor: () => editor?.commands.scrollIntoView(),
selectAll: () => editor?.commands.selectAll(),
toggleBold: () => editor?.chain().focus().toggleBold().run(),
toggleItalic: () => editor?.chain().focus().toggleItalic().run(),
toggleTaskList: () => editor?.chain().focus().toggleTaskList().run(),
}),
[editor],
);
return (
<div
className={cn(
"flex flex-col justify-start items-start relative w-full bg-inherit overflow-y-auto overflow-x-hidden",
isFocusMode ? "flex-1" : `h-auto ${EDITOR_HEIGHT.normal}`,
className,
)}
>
<TiptapEditorContent editor={editor} className="w-full" />
</div>
);
});
export default TiptapEditor;
- Step 5: Add minimal global styles
Append to web/src/index.css:
/* Tiptap WYSIWYG memo editor */
.memo-wysiwyg p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
opacity: 0.7;
pointer-events: none;
}
.memo-wysiwyg ul,
.memo-wysiwyg ol {
padding-left: 1.5rem;
}
.memo-wysiwyg ul {
list-style: disc;
}
.memo-wysiwyg ol {
list-style: decimal;
}
.memo-wysiwyg ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.memo-wysiwyg ul[data-type="taskList"] li {
display: flex;
gap: 0.5rem;
}
.memo-wysiwyg ul[data-type="taskList"] li > label {
flex: 0 0 auto;
user-select: none;
}
.memo-wysiwyg ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.memo-wysiwyg blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
opacity: 0.9;
}
.memo-wysiwyg pre {
background: var(--muted);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-family: var(--font-mono, monospace);
font-size: 0.875rem;
white-space: pre-wrap;
}
.memo-wysiwyg code {
background: var(--muted);
border-radius: 0.25rem;
padding: 0 0.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.875em;
}
.memo-wysiwyg pre code {
background: transparent;
padding: 0;
}
(If var(--border) / var(--muted) are not defined in this project's Tailwind theme, check web/src/index.css for the actual CSS custom property names used by shadcn components — they exist because classes like bg-muted/border-border are used throughout — and substitute the real names.)
- Step 6: Run the test until green
pnpm test -- tiptap-editor-controller
Expected: PASS.
Troubleshooting:
- jsdom missing APIs when the ProseMirror view mounts (
getClientRects,elementFromPoint): add toweb/tests/setup.ts:
// ProseMirror probes layout APIs jsdom doesn't implement.
if (typeof document !== "undefined") {
if (!document.elementFromPoint) {
document.elementFromPoint = () => null;
}
if (typeof Range !== "undefined" && !Range.prototype.getClientRects) {
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () =>
({ x: 0, y: 0, top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0, toJSON: () => ({}) }) as DOMRect;
}
}
-
useEditorreturningnullon first render in React 19 → wrap assertions inawait waitFor(...)from @testing-library/react, or passimmediatelyRender: truetouseEditor(Tiptap v3 option) — preferimmediatelyRender: truesince this app is client-only. -
Step 7: Verify the corpus is still green, lint, commit
pnpm lint && pnpm test
git add web/src web/tests
git commit -m "feat(web): add tiptap wysiwyg editor component behind EditorController"
Task 7: Dual-mode EditorContent, mode toggle UI, localStorage persistence
Files:
-
Create:
web/src/components/MemoEditor/editorMode.ts -
Modify:
web/src/components/MemoEditor/state/types.ts -
Modify:
web/src/components/MemoEditor/state/actions.ts -
Modify:
web/src/components/MemoEditor/state/reducer.ts -
Modify:
web/src/components/MemoEditor/state/context.tsx -
Modify:
web/src/components/MemoEditor/components/EditorContent.tsx -
Modify:
web/src/components/MemoEditor/components/EditorToolbar.tsx -
Modify:
web/src/locales/en.json -
Test:
web/tests/editor-mode.test.ts,web/tests/editor-mode-toggle.test.tsx -
Step 1: Write the failing preference-helper test
web/tests/editor-mode.test.ts:
import { beforeEach, describe, expect, it } from "vitest";
import { getPreferredEditorMode, setPreferredEditorMode } from "@/components/MemoEditor/editorMode";
describe("editor mode preference", () => {
beforeEach(() => localStorage.clear());
it("defaults to wysiwyg", () => {
expect(getPreferredEditorMode()).toBe("wysiwyg");
});
it("persists raw mode", () => {
setPreferredEditorMode("raw");
expect(getPreferredEditorMode()).toBe("raw");
expect(localStorage.getItem("memos-editor-mode")).toBe("raw");
});
it("ignores garbage values", () => {
localStorage.setItem("memos-editor-mode", "weird");
expect(getPreferredEditorMode()).toBe("wysiwyg");
});
});
- Step 2: Run it to verify it fails, then implement the helper
pnpm test -- editor-mode.test
Expected: FAIL (module missing). Then create web/src/components/MemoEditor/editorMode.ts:
export type EditorMode = "wysiwyg" | "raw";
const STORAGE_KEY = "memos-editor-mode";
/** Per-device editor mode preference. WYSIWYG is the default for everyone. */
export function getPreferredEditorMode(): EditorMode {
try {
return localStorage.getItem(STORAGE_KEY) === "raw" ? "raw" : "wysiwyg";
} catch {
return "wysiwyg";
}
}
export function setPreferredEditorMode(mode: EditorMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// localStorage unavailable (e.g. blocked storage) — preference won't persist.
}
}
Re-run: pnpm test -- editor-mode.test → PASS.
- Step 3: Add editor mode to the reducer state
In web/src/components/MemoEditor/state/types.ts:
- Add the import at the top:
import { getPreferredEditorMode, type EditorMode } from "../editorMode";
- In
EditorState["ui"], add a field afterisComposing: boolean;:
editorMode: EditorMode;
- Add to the
EditorActionunion:
| { type: "SET_EDITOR_MODE"; payload: EditorMode }
- In
initialState.ui, afterisComposing: false,add:
editorMode: "wysiwyg",
- After the
initialStatedeclaration, add:
/**
* Fresh initial state for a mounting editor. Reads the persisted mode
* preference at call time (not module load) so newly opened editors honor a
* toggle made earlier in the session.
*/
export function createInitialState(): EditorState {
return {
...initialState,
ui: { ...initialState.ui, editorMode: getPreferredEditorMode() },
};
}
In web/src/components/MemoEditor/state/actions.ts, add to editorActions (and add import type { EditorMode } from "../editorMode";):
setEditorMode: (mode: EditorMode): EditorAction => ({
type: "SET_EDITOR_MODE",
payload: mode,
}),
In web/src/components/MemoEditor/state/reducer.ts:
- Change the import line
import { initialState } from "./types";toimport { createInitialState } from "./types"; - Add a case before
RESET:
case "SET_EDITOR_MODE":
return {
...state,
ui: {
...state.ui,
editorMode: action.payload,
},
};
- Change the
RESETcase to re-read the persisted preference:
case "RESET":
return createInitialState();
In web/src/components/MemoEditor/state/context.tsx, change the import of initialState to createInitialState and the useReducer line to lazy-init:
import { createInitialState } from "./types";
// …
const [state, dispatch] = useReducer(editorReducer, initialEditorState, (provided) => provided ?? createInitialState());
Check web/src/components/MemoEditor/state/index.ts re-exports and add createInitialState / EditorMode exports if it enumerates names explicitly.
- Step 4: Write the failing dual-mode test
web/tests/editor-mode-toggle.test.tsx:
import { 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";
vi.mock("@/components/MemoEditor/Editor/TagSuggestions", () => ({ default: () => null }));
vi.mock("@/components/MemoEditor/Editor/SlashCommands", () => ({ default: () => null }));
function ModeProbe() {
const { state, actions, dispatch } = useEditorContext();
return (
<button
type="button"
data-testid="probe-toggle"
onClick={() => dispatch(actions.setEditorMode(state.ui.editorMode === "wysiwyg" ? "raw" : "wysiwyg"))}
>
{state.ui.editorMode}
</button>
);
}
function renderDualEditor() {
render(
<EditorProvider>
<EditorContent placeholder="memo" />
<ModeProbe />
</EditorProvider>,
);
}
describe("editor mode switching", () => {
it("mounts the WYSIWYG editor by default", () => {
localStorage.clear();
renderDualEditor();
expect(document.querySelector(".memo-wysiwyg")).not.toBeNull();
expect(screen.queryByPlaceholderText("memo")).toBeNull();
});
it("mounts the textarea when the persisted preference is raw", () => {
localStorage.setItem("memos-editor-mode", "raw");
renderDualEditor();
expect(screen.getByPlaceholderText("memo")).toBeInTheDocument();
expect(document.querySelector(".memo-wysiwyg")).toBeNull();
});
it("hands content across when toggling raw → wysiwyg", () => {
localStorage.setItem("memos-editor-mode", "raw");
renderDualEditor();
const textarea = screen.getByPlaceholderText("memo") as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: "hello **world**" } });
fireEvent.click(screen.getByTestId("probe-toggle"));
const wysiwyg = document.querySelector(".memo-wysiwyg");
expect(wysiwyg).not.toBeNull();
expect(wysiwyg?.textContent).toContain("hello world");
expect(wysiwyg?.querySelector("strong")?.textContent).toBe("world");
});
});
Run pnpm test -- editor-mode-toggle — expected FAIL (EditorContent always renders the textarea).
- Step 5: Make EditorContent host both editors
Replace web/src/components/MemoEditor/components/EditorContent.tsx with:
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react";
import Editor, { type EditorRefActions } from "../Editor";
import { createTextareaController } from "../Editor/controllerAdapter";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useEditorContext } from "../state";
import TiptapEditor from "../TiptapEditor";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
import type { EditorController } from "../types/editorController";
/**
* Hosts one of the two editor implementations behind the EditorController
* contract, selected by state.ui.editorMode. Mode switching is a markdown
* handoff: both editors serialize into state.content on every change, so the
* incoming editor simply initializes from it.
*/
export const EditorContent = forwardRef<EditorController, EditorContentProps>(({ placeholder }, ref) => {
const { state, actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
const mode = state.ui.editorMode;
const textareaActionsRef = useRef<EditorRefActions>(null);
const tiptapControllerRef = useRef<EditorController>(null);
const textareaController = useMemo(() => createTextareaController(() => textareaActionsRef.current), []);
const modeRef = useRef(mode);
modeRef.current = mode;
const getActive = useCallback(
(): EditorController | null => (modeRef.current === "wysiwyg" ? tiptapControllerRef.current : textareaController),
[textareaController],
);
useImperativeHandle(
ref,
(): EditorController => ({
focus: () => getActive()?.focus(),
hasFocus: () => getActive()?.hasFocus() ?? false,
isEmpty: () => getActive()?.isEmpty() ?? true,
getMarkdown: () => getActive()?.getMarkdown() ?? "",
setMarkdown: (markdown) => getActive()?.setMarkdown(markdown),
insertMarkdown: (markdown) => getActive()?.insertMarkdown(markdown),
scrollToCursor: () => getActive()?.scrollToCursor(),
selectAll: () => getActive()?.selectAll(),
toggleBold: () => getActive()?.toggleBold(),
toggleItalic: () => getActive()?.toggleItalic(),
toggleTaskList: () => getActive()?.toggleTaskList(),
}),
[getActive],
);
const { dragHandlers } = useDragAndDrop((files: FileList) => {
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
});
const handleCompositionStart = () => {
dispatch(actions.setComposing(true));
};
const handleCompositionEnd = () => {
dispatch(actions.setComposing(false));
};
const handleContentChange = (content: string) => {
dispatch(actions.updateContent(content));
};
const handlePaste = (event: React.ClipboardEvent<Element>) => {
const clipboard = event.clipboardData;
if (!clipboard) return;
const files: File[] = [];
if (clipboard.items && clipboard.items.length > 0) {
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file) files.push(file);
}
} else if (clipboard.files && clipboard.files.length > 0) {
files.push(...Array.from(clipboard.files));
}
if (files.length === 0) return;
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault();
};
return (
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
{mode === "wysiwyg" ? (
<TiptapEditor
ref={tiptapControllerRef}
className="memo-editor-content"
initialContent={state.content}
placeholder={placeholder || ""}
isFocusMode={state.ui.isFocusMode}
onContentChange={handleContentChange}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
) : (
<Editor
ref={textareaActionsRef}
className="memo-editor-content"
initialContent={state.content}
placeholder={placeholder || ""}
isFocusMode={state.ui.isFocusMode}
isInIME={state.ui.isComposing}
onContentChange={handleContentChange}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
)}
</div>
);
});
EditorContent.displayName = "EditorContent";
Run pnpm test -- editor-mode-toggle → PASS.
- Step 6: Add the toggle button to the toolbar + i18n strings
In web/src/locales/en.json, inside the "editor" object (alphabetical-ish placement next to its siblings), add:
"switch-to-raw": "Edit raw Markdown",
"switch-to-wysiwyg": "Edit rich text",
In web/src/components/MemoEditor/components/EditorToolbar.tsx:
- Add imports:
import { CodeXmlIcon, TypeIcon } from "lucide-react";
import { setPreferredEditorMode } from "../editorMode";
- Inside the component, add a handler after
handleToggleFocusMode:
const handleToggleEditorMode = () => {
const next = state.ui.editorMode === "wysiwyg" ? "raw" : "wysiwyg";
dispatch(actions.setEditorMode(next));
setPreferredEditorMode(next);
};
- In the left button group, right after
<InsertMenu …/>, add:
<Button
variant="ghost"
size="icon"
className="ml-1 shadow-none text-muted-foreground"
onClick={handleToggleEditorMode}
title={state.ui.editorMode === "wysiwyg" ? t("editor.switch-to-raw") : t("editor.switch-to-wysiwyg")}
>
{state.ui.editorMode === "wysiwyg" ? <CodeXmlIcon className="size-4" /> : <TypeIcon className="size-4" />}
</Button>
- Step 7: Add a toolbar toggle test
Append to web/tests/editor-mode-toggle.test.tsx (extend the existing file; add the import import { EditorToolbar } from "@/components/MemoEditor/components/EditorToolbar";):
describe("toolbar mode toggle button", () => {
it("switches mode and persists the preference", async () => {
localStorage.clear();
render(
<EditorProvider>
<EditorContent placeholder="memo" />
<EditorToolbar onSave={vi.fn()} onAudioRecorderClick={vi.fn()} />
</EditorProvider>,
);
// i18n resources load through a lazy backend — await the translated title.
fireEvent.click(await screen.findByTitle("Edit raw Markdown"));
expect(screen.getByPlaceholderText("memo")).toBeInTheDocument();
expect(localStorage.getItem("memos-editor-mode")).toBe("raw");
expect(await screen.findByTitle("Edit rich text")).toBeInTheDocument();
});
});
(If the translated title never resolves because the test environment leaves i18next uninitialized, t() returns the key — query findByTitle("editor.switch-to-raw") instead and leave a comment. Check how the suite behaves before reaching for an i18n test bootstrap.)
(If EditorToolbar pulls in providers that explode in jsdom — e.g. VisibilitySelector's radix Select — mock the offending child: vi.mock("@/components/MemoEditor/Toolbar/VisibilitySelector", () => ({ default: () => null })); and same for InsertMenu if needed. Note Toolbar/index.ts may re-export; mock the exact module path the component imports.)
- Step 8: Full suite + lint + commit
pnpm lint && pnpm test
git add web/src web/tests
git commit -m "feat(web): dual-mode memo editor with wysiwyg/raw toggle persisted per device"
Task 8: Tag suggestions popup in WYSIWYG mode
Files:
-
Create:
web/src/components/MemoEditor/TiptapEditor/suggestionMenu.tsx -
Create:
web/src/components/MemoEditor/TiptapEditor/TagSuggestion.ts -
Modify:
web/src/components/MemoEditor/TiptapEditor/index.tsx -
Test:
web/tests/suggestion-menu.test.tsx -
Step 1: Write the failing popup-component test
web/tests/suggestion-menu.test.tsx:
import { fireEvent, render, screen } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import { SuggestionMenu, type SuggestionMenuHandle } from "@/components/MemoEditor/TiptapEditor/suggestionMenu";
function setup(items = ["alpha", "beta", "gamma"]) {
const command = vi.fn();
const ref = createRef<SuggestionMenuHandle>();
render(
<SuggestionMenu
ref={ref}
items={items}
command={command}
getItemKey={(item: string) => item}
renderItem={(item: string) => <span>#{item}</span>}
/>,
);
return { command, ref };
}
const keyDown = (key: string) => ({ event: new KeyboardEvent("keydown", { key }) }) as never;
describe("SuggestionMenu", () => {
it("renders all items", () => {
setup();
expect(screen.getByText("#alpha")).toBeInTheDocument();
expect(screen.getByText("#gamma")).toBeInTheDocument();
});
it("navigates with arrows and selects with Enter", () => {
const { command, ref } = setup();
expect(ref.current?.onKeyDown(keyDown("ArrowDown"))).toBe(true);
expect(ref.current?.onKeyDown(keyDown("Enter"))).toBe(true);
expect(command).toHaveBeenCalledWith("beta");
});
it("selects on mouse down", () => {
const { command } = setup();
fireEvent.mouseDown(screen.getByText("#gamma"));
expect(command).toHaveBeenCalledWith("gamma");
});
it("renders nothing for an empty list and lets keys pass through", () => {
const { ref } = setup([]);
expect(document.querySelector("[data-suggestion-menu]")).toBeNull();
expect(ref.current?.onKeyDown(keyDown("Enter"))).toBe(false);
});
});
Run pnpm test -- suggestion-menu — expected FAIL (module missing).
- Step 2: Implement the shared menu + suggestion renderer factory
web/src/components/MemoEditor/TiptapEditor/suggestionMenu.tsx:
import { ReactRenderer } from "@tiptap/react";
import type { SuggestionKeyDownProps, SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import { forwardRef, useEffect, useImperativeHandle, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface SuggestionMenuHandle {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}
interface SuggestionMenuProps<T> {
items: T[];
command: (item: T) => void;
renderItem: (item: T, isSelected: boolean) => ReactNode;
getItemKey: (item: T) => string;
}
// Visual parity with the textarea editor's popup (Editor/SuggestionsPopup.tsx).
const MENU_STYLES = {
container:
"z-50 p-1 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden",
item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
};
function SuggestionMenuInner<T>(
{ items, command, renderItem, getItemKey }: SuggestionMenuProps<T>,
ref: React.ForwardedRef<SuggestionMenuHandle>,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => setSelectedIndex(0), [items]);
useImperativeHandle(
ref,
() => ({
onKeyDown: ({ event }) => {
if (items.length === 0) {
return false;
}
if (event.key === "ArrowDown") {
setSelectedIndex((index) => (index + 1) % items.length);
return true;
}
if (event.key === "ArrowUp") {
setSelectedIndex((index) => (index - 1 + items.length) % items.length);
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
command(items[selectedIndex]);
return true;
}
return false;
},
}),
[items, selectedIndex, command],
);
if (items.length === 0) {
return null;
}
return (
<div data-suggestion-menu className={MENU_STYLES.container}>
{items.map((item, index) => (
<div
key={getItemKey(item)}
onMouseDown={(event) => {
event.preventDefault();
command(item);
}}
className={cn(MENU_STYLES.item, index === selectedIndex && "bg-accent text-accent-foreground")}
>
{renderItem(item, index === selectedIndex)}
</div>
))}
</div>
);
}
export const SuggestionMenu = forwardRef(SuggestionMenuInner) as <T>(
props: SuggestionMenuProps<T> & { ref?: React.ForwardedRef<SuggestionMenuHandle> },
) => ReturnType<typeof SuggestionMenuInner>;
/**
* Builds the Suggestion-plugin `render` lifecycle: mounts SuggestionMenu in a
* floating container positioned at the trigger's caret rect. No tippy —
* a plain absolutely-positioned element on document.body.
*/
export function createSuggestionRenderer<T>(menu: {
renderItem: (item: T, isSelected: boolean) => ReactNode;
getItemKey: (item: T) => string;
}): SuggestionOptions<T>["render"] {
return () => {
let renderer: ReactRenderer<SuggestionMenuHandle> | null = null;
let container: HTMLDivElement | null = null;
const reposition = (props: SuggestionProps<T>) => {
const rect = props.clientRect?.();
if (!rect || !container) {
return;
}
container.style.left = `${rect.left + window.scrollX}px`;
container.style.top = `${rect.bottom + window.scrollY + 4}px`;
};
const destroy = () => {
renderer?.destroy();
container?.remove();
renderer = null;
container = null;
};
const menuProps = (props: SuggestionProps<T>) => ({
items: props.items,
command: (item: T) => props.command(item),
renderItem: menu.renderItem,
getItemKey: menu.getItemKey,
});
return {
onStart: (props) => {
renderer = new ReactRenderer(SuggestionMenu, { props: menuProps(props), editor: props.editor });
container = document.createElement("div");
container.style.position = "absolute";
container.style.zIndex = "50";
container.appendChild(renderer.element);
document.body.appendChild(container);
reposition(props);
},
onUpdate: (props) => {
renderer?.updateProps(menuProps(props));
reposition(props);
},
onKeyDown: (props) => {
if (props.event.key === "Escape") {
destroy();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: destroy,
};
};
}
Run pnpm test -- suggestion-menu → PASS.
- Step 3: Implement the TagSuggestion extension
web/src/components/MemoEditor/TiptapEditor/TagSuggestion.ts:
import { Extension } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createElement } from "react";
import { createSuggestionRenderer } from "./suggestionMenu";
export interface TagSuggestionOptions {
/** Getter (not a snapshot) so the popup always sees freshly fetched tags. */
getTags: () => string[];
}
const MAX_SUGGESTIONS = 20;
/** `#` popup backed by the user's existing tags; inserts a Tag node + space. */
export const TagSuggestion = Extension.create<TagSuggestionOptions>({
name: "tagSuggestion",
addOptions() {
return { getTags: () => [] };
},
addProseMirrorPlugins() {
return [
Suggestion<string>({
editor: this.editor,
pluginKey: new PluginKey("tagSuggestion"),
char: "#",
allowSpaces: false,
items: ({ query }) => {
const tags = this.options.getTags();
const q = query.toLowerCase();
const filtered = q ? tags.filter((tag) => tag.toLowerCase().includes(q)) : tags;
return filtered.slice(0, MAX_SUGGESTIONS);
},
command: ({ editor, range, props: tag }) => {
editor
.chain()
.focus()
.insertContentAt(range, [
{ type: "tag", attrs: { tag } },
{ type: "text", text: " " },
])
.run();
},
render: createSuggestionRenderer<string>({
getItemKey: (tag) => tag,
renderItem: (tag) =>
createElement("span", { className: "truncate" }, createElement("span", { className: "text-muted-foreground mr-1" }, "#"), tag),
}),
}),
];
},
});
- Step 4: Wire tags into the editor component
In web/src/components/MemoEditor/TiptapEditor/index.tsx:
- Add imports:
import { useMemo, useRef } from "react";
import { matchPath } from "react-router-dom";
import { useTagCounts } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import { TagSuggestion } from "./TagSuggestion";
- Inside the component before
useEditor, mirror the textarea's tag sourcing (Editor/TagSuggestions.tsx):
// On the explore page suggestions include all users' tags; otherwise the
// current user's. Same sourcing as the raw editor's TagSuggestions.
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
const sortedTags = useMemo(
() =>
Object.entries(tagCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag),
[tagCount],
);
const tagsRef = useRef<string[]>([]);
tagsRef.current = sortedTags;
- Add to the
useEditorextensions array:
TagSuggestion.configure({ getTags: () => tagsRef.current }),
- Step 5: Fix tests that now need the query hook mocked
useTagCounts is a react-query hook; any test rendering TiptapEditor without a QueryClientProvider will now throw. Add to the TOP of web/tests/tiptap-editor-controller.test.tsx AND web/tests/editor-mode-toggle.test.tsx:
vi.mock("@/hooks/useUserQueries", async (importOriginal) => ({
...(await importOriginal<object>()),
useTagCounts: () => ({ data: {} }),
}));
(If importOriginal drags in transitive modules that fail at import time, replace with a plain object mock exporting just useTagCounts and any names those tests touch.)
- Step 6: Full suite + lint + commit
pnpm lint && pnpm test
git add web/src web/tests
git commit -m "feat(web): tag suggestion popup for wysiwyg editor"
Task 9: Slash commands in WYSIWYG mode
Files:
-
Create:
web/src/components/MemoEditor/TiptapEditor/SlashCommand.ts -
Modify:
web/src/components/MemoEditor/TiptapEditor/index.tsx -
Test:
web/tests/tiptap-slash-commands.test.ts -
Step 1: Write the failing test
web/tests/tiptap-slash-commands.test.ts:
import { Editor } from "@tiptap/core";
import { afterEach, describe, expect, it } from "vitest";
import { buildExtensions } from "@/components/MemoEditor/TiptapEditor/extensions";
import { slashCommandItems } from "@/components/MemoEditor/TiptapEditor/SlashCommand";
let editor: Editor;
function applyCommand(name: string, content = "") {
editor = new Editor({ extensions: buildExtensions(), content, contentType: "markdown" });
const item = slashCommandItems.find((candidate) => candidate.name === name);
if (!item) throw new Error(`unknown slash command: ${name}`);
// Simulate a trigger at the end of the document: range covers a typed "/".
editor.commands.insertContent("/");
const end = editor.state.doc.content.size;
item.apply(editor, { from: end - 1, to: end });
return editor.getMarkdown();
}
afterEach(() => editor?.destroy());
describe("WYSIWYG slash commands", () => {
it("todo converts the block into a task list", () => {
expect(applyCommand("todo")).toBe("- [ ]");
});
it("code converts the block into a code block", () => {
expect(applyCommand("code")).toContain("```");
});
it("link inserts a markdown link", () => {
expect(applyCommand("link")).toContain("[text](url)");
});
it("table inserts a preserved markdown table", () => {
const markdown = applyCommand("table");
expect(markdown).toContain("| Header | Header |");
expect(markdown).toContain("| ------ | ------ |");
});
});
Run pnpm test -- tiptap-slash-commands — expected FAIL (module missing).
Note: if the todo expectation fails on exact whitespace (e.g. "- [ ] " vs "- [ ]"), assert with expect(markdown.trimEnd()).toBe("- [ ]") — the meaningful claim is the empty task item, not trailing-space geometry.
- Step 2: Implement the SlashCommand extension
web/src/components/MemoEditor/TiptapEditor/SlashCommand.ts:
import { Extension, type Editor, type Range } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createElement } from "react";
import { createSuggestionRenderer } from "./suggestionMenu";
export interface SlashCommandItem {
name: string;
apply: (editor: Editor, range: Range) => void;
}
// WYSIWYG counterparts of the raw editor's commands (Editor/commands.ts):
// the same four entries, realized as editor commands instead of raw strings.
export const slashCommandItems: SlashCommandItem[] = [
{
name: "todo",
apply: (editor, range) => editor.chain().focus().deleteRange(range).toggleTaskList().run(),
},
{
name: "code",
apply: (editor, range) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
name: "link",
apply: (editor, range) =>
editor.chain().focus().deleteRange(range).insertContent("[text](url)", { contentType: "markdown" }).run(),
},
{
name: "table",
apply: (editor, range) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent("| Header | Header |\n| ------ | ------ |\n| Cell | Cell |", { contentType: "markdown" })
.run(),
},
];
/** `/` command popup; replaces the raw editor's SlashCommands in WYSIWYG mode. */
export const SlashCommand = Extension.create({
name: "slashCommand",
addProseMirrorPlugins() {
return [
Suggestion<SlashCommandItem>({
editor: this.editor,
pluginKey: new PluginKey("slashCommand"),
char: "/",
allowSpaces: false,
items: ({ query }) => {
const q = query.toLowerCase();
return q ? slashCommandItems.filter((item) => item.name.startsWith(q)) : slashCommandItems;
},
command: ({ editor, range, props: item }) => {
item.apply(editor, range);
},
render: createSuggestionRenderer<SlashCommandItem>({
getItemKey: (item) => item.name,
renderItem: (item) =>
createElement(
"span",
{ className: "tracking-wide" },
createElement("span", { className: "text-muted-foreground" }, "/"),
item.name,
),
}),
}),
];
},
});
- Step 3: Register in the editor component
In web/src/components/MemoEditor/TiptapEditor/index.tsx add the import and append to the useEditor extensions array (after TagSuggestion.configure(…)):
import { SlashCommand } from "./SlashCommand";
// …
SlashCommand,
- Step 4: Run tests + lint + commit
pnpm lint && pnpm test
git add web/src web/tests/tiptap-slash-commands.test.ts
git commit -m "feat(web): slash command popup for wysiwyg editor"
Task 10: Load guard (round-trip tripwire on opening existing memos)
When opening an existing memo in WYSIWYG mode, verify the round trip is lossless; if not, switch this editor session to raw mode (without persisting the preference) and tell the user. Expected never to fire — PreservedBlock should make every input lossless — but this is the safety net the fidelity contract demands.
Files:
-
Modify:
web/src/components/MemoEditor/hooks/useMemoInit.ts -
Modify:
web/src/locales/en.json -
Test:
web/tests/editor-load-guard.test.tsx -
Step 1: Add the i18n string
In web/src/locales/en.json, in the "editor" object next to the Task-7 keys:
"unsupported-syntax-raw-mode": "This memo contains syntax the rich-text editor can't safely edit, so it opened in raw Markdown mode.",
- Step 2: Write the failing test
web/tests/editor-load-guard.test.tsx:
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { create } from "@bufbuild/protobuf";
import { useMemoInit } from "@/components/MemoEditor/hooks";
import { EditorProvider, useEditorContext } 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", () => ({ toast: Object.assign(toastMock, { error: vi.fn(), success: vi.fn() }) }));
const isLosslessMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/MemoEditor/TiptapEditor/markdownCodec", () => ({
isLosslessRoundTrip: isLosslessMock,
}));
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>;
}
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();
// 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");
});
});
(Adjust the Memo construction to this repo's proto runtime: check how other tests build a Memo — e.g. grep MemoSchema in web/tests/ — and mirror that. If none exists, create(MemoSchema, { name, content }) with @bufbuild/protobuf is correct for protobuf-es v2.)
Run pnpm test -- editor-load-guard — expected FAIL (mode stays wysiwyg, no toast).
- Step 3: Implement the guard in useMemoInit
In web/src/components/MemoEditor/hooks/useMemoInit.ts:
- Add imports:
import { toast } from "react-hot-toast";
import { useTranslate } from "@/utils/i18n";
import { getPreferredEditorMode } from "../editorMode";
import { isLosslessRoundTrip } from "../TiptapEditor/markdownCodec";
-
Add
const t = useTranslate();next to the existinguseEditorContext()call. -
In the
if (memo)branch, afterdispatch(actions.initMemo(initialState));, add:
// Load guard (tripwire): if the WYSIWYG round trip would change this
// memo's meaning, edit it raw for this session. Preference untouched.
if (getPreferredEditorMode() === "wysiwyg" && initialState.content && !isLosslessRoundTrip(initialState.content)) {
console.warn("memo content failed wysiwyg round-trip; falling back to raw editor", memo.name);
dispatch(actions.setEditorMode("raw"));
toast(t("editor.unsupported-syntax-raw-mode"));
}
- Add
tto the effect's dependency array.
- Step 4: Run tests + lint + commit
pnpm lint && pnpm test
git add web/src web/tests/editor-load-guard.test.tsx
git commit -m "feat(web): raw-mode load guard for memos the wysiwyg editor cannot round-trip"
Task 11: Final verification, docs, release build
Files:
-
Modify:
web/src/components/MemoEditor/README.md -
Step 1: Full verification
cd /Users/steven/Projects/usememos/memos/web
pnpm lint && pnpm test && pnpm build
Expected: all PASS; the build completes. Note the reported gzip size of chunks containing tiptap — the budget from the spec is ~100 KB gzipped for the editor stack; if it lands wildly above (>150 KB), flag it in the final report (do not silently accept).
- Step 2: Update the MemoEditor README
Read web/src/components/MemoEditor/README.md and update it to describe the new architecture: the EditorController contract, the two implementations (Editor/ textarea = raw mode, TiptapEditor/ = WYSIWYG default), the mode toggle + localStorage key memos-editor-mode, the markdown codec + corpus tests as the fidelity gate, and the load guard. Keep its existing tone/format; do not document internals that the code already states.
- Step 3: Manual QA checklist (requires a running dev server:
pnpm devagainst a local memos server)
Walk through and record results — these are the spec's manual-QA items that jsdom cannot cover:
- Type
**bold**,# heading,-list,> quote,```fence — each converts live as you type. - Type
#— tag popup opens, filter works, Enter inserts a styled tag chip; saved memo renders the tag. - Type
/— command popup opens; todo/code/link/table all insert. - Paste an image — it appears in the attachment strip (upload path), not as text.
- Select text and paste a URL over it — it becomes a link.
- Toggle to raw mode mid-edit — markdown matches what was typed; toggle back — nothing lost; reload — preference remembered.
- Edit a memo containing a table +
$math$— both visible as literal mono text; save without touching them; verify the stored content is byte-identical (compare in raw mode before/after). - CJK IME composition (macOS Japanese/Chinese input): no duplicated or dropped characters while composing.
- Focus mode: editor grows to fill; typing/scrolling fine.
- Mobile Safari/Chrome (or responsive emulation): soft keyboard works, popups usable.
- Ctrl/Cmd+B/I in WYSIWYG; Ctrl/Cmd+Enter saves from inside the editor.
- Draft persistence: type in a new memo, reload the page, content restored (in both modes).
- Step 4: Final commit
cd /Users/steven/Projects/usememos/memos
git add web/src/components/MemoEditor/README.md
git commit -m "docs(web): document dual-mode memo editor architecture"
Then use the superpowers:finishing-a-development-branch skill to decide merge/PR handling.
Self-review notes (already applied)
- Spec coverage: raw-mode toggle (Task 7), fidelity contract + corpus-first spike (Tasks 2–3), v1 live set via StarterKit/TaskList/Tag (Tasks 2, 4), tables/math/inline-HTML literal (Task 3), suggestion popups (Tasks 8–9), file paste/drag (Task 6 + existing container drag), load guard (Task 10), draft/save path untouched (state.content unchanged throughout), manual QA list (Task 11). Out-of-scope items from the spec are not implemented anywhere.
- Known accepted deviations, each annotated inline: serialize-on-update instead of debounce (Task header note);
##xtokenizer edge differs from remark-tag but serializes identically (Task 4); slashlinkcommand can't preselect the URL placeholder in WYSIWYG (raw mode retains that nicety). - Type-consistency check:
EditorControllergainsselectAll()in Task 6 — implementers executing tasks out of order should add it to the interface + textarea adapter as described there; all later facades (Task 7) already include it.