memos/web/src/components/MemoEditor
johnnyjoygh e3c231fcac feat(web): add code block and strikethrough to formatting toolbar
Reorganize the toolbar into inline marks (bold, italic, strikethrough,
inline code) and blocks (lists, fenced code block), with the Type glyph
for the paragraph/heading picker. Marks derive from the shared MARKS
table; code block toggling reuses the same selection-probe lookup so
toggle-off always agrees with the button's active state. A fresh empty
strikethrough pair (~~~~) parses as a tilde code fence, so empty-pair
removal and code-block detection recognize doubled mark tokens.
2026-07-07 22:08:33 +08:00
..
components feat(web): add max-columns memo feed layout 2026-07-06 23:55:36 +08:00
Editor feat(web): add code block and strikethrough to formatting toolbar 2026-07-07 22:08:33 +08:00
formatting feat(web): add code block and strikethrough to formatting toolbar 2026-07-07 22:08:33 +08:00
hooks feat(web): add code block and strikethrough to formatting toolbar 2026-07-07 22:08:33 +08:00
services refactor(editor): modularize formatting and simplify editor state 2026-06-22 23:51:59 +08:00
state refactor: rebuild the editor on CodeMirror as decorated source 2026-06-28 22:59:14 +08:00
Toolbar feat(web): add code block and strikethrough to formatting toolbar 2026-07-07 22:08:33 +08:00
types feat(web): add max-columns memo feed layout 2026-07-06 23:55:36 +08:00
utils feat(memo): create memos on the selected calendar date (#5925) 2026-05-02 11:53:50 +08:00
constants.ts chore(editor): toggleable formatting toolbar 2026-07-02 09:19:34 +08:00
index.tsx feat(web): add max-columns memo feed layout 2026-07-06 23:55:36 +08:00
README.md refactor: rebuild the editor on CodeMirror as decorated source 2026-06-28 22:59:14 +08:00

MemoEditor Architecture

Overview

MemoEditor is a three-layer component. At its core is a single editor — Editor/, a CodeMirror 6 "decorated source" editor. It stores the memo as raw markdown, verbatim (no parse/serialize round-trip) and styles that source in place with CodeMirror decorations: the markers (#, *, `, list bullets, fences) stay visible but de-emphasized while the styled text leads. There is one editor and one storage format; everything above the editor boundary talks markdown through the EditorController contract.

Architecture

┌─────────────────────────────────────────┐
│   Presentation Layer (Components)       │
│   - EditorToolbar, EditorContent, etc.  │
└─────────────────┬───────────────────────┘
                  │ EditorController
┌─────────────────▼───────────────────────┐
│   State Layer (Reducer + Context)       │
│   - state/, useEditorContext()          │
│   - state.content  ← markdown (the      │
│     single source of truth)             │
└─────────────────┬───────────────────────┘
                  │
┌─────────────────▼───────────────────────┐
│   Service Layer (Business Logic)        │
│   - services/ (pure functions)          │
└─────────────────────────────────────────┘

Directory Structure

MemoEditor/
├── state/                  # State management (reducer, actions, context)
├── services/               # Business logic (pure functions)
├── components/             # UI components
│   ├── EditorContent.tsx   # Hosts Editor; forwards its EditorController ref
│   ├── EditorToolbar.tsx   # Toolbar
│   └── ...
├── hooks/                  # React hooks (utilities)
├── Editor/           # The CodeMirror 6 decorated-source editor
│   ├── index.tsx               # React wrapper: mounts the EditorView, owns the
│   │                           #   controller refs, syncs initialContent in/out
│   ├── extensions.ts           # buildEditorExtensions(): assembles the CM extension set
│   ├── theme.ts                # Syntax-highlight style + editor theme (CSS-var colors)
│   ├── tagMentionDecorations.ts# ViewPlugin that decorates #tag / @mention spans
│   ├── tagAutocomplete.ts      # CM autocompletion source for #tag
│   ├── formatting.ts           # FormattingController impl (toggle marks, headings, lists)
│   └── controller.ts           # EditorController impl over an EditorView
├── formatting/
│   └── commands.ts         # Backend-agnostic catalog of formatting verbs
├── Toolbar/                # Toolbar sub-components (InsertMenu, VisibilitySelector)
├── constants.ts
└── types/
    └── editorController.ts # EditorController / FormattingController interfaces

Key Concepts

State Management

Uses useReducer + Context for predictable state transitions. All state changes go through action creators.

state.content holds the document as a markdown string and is the single source of truth. Because the editor stores markdown verbatim, state.content is exactly the editor's document — there is no encoding or normalization step.

The editor contract

types/editorController.ts defines EditorController — focus, getMarkdown, setMarkdown, insertMarkdown, selectAll, scrollToCursor, plus an optional formatting capability. Callers outside the editor implementation use this interface exclusively and never reach into CodeMirror internals.

Editor/controller.ts implements EditorController over a CodeMirror EditorView: getMarkdown is just view.state.doc.toString(), setMarkdown replaces the whole document, and insertMarkdown block-pads the insertion so it lands as its own block.

FormattingController (same file in types/) is the rich-formatting surface the focus-mode FormattingToolbar drives: run(commandId, ctx?), getActiveFormats(), getSelectedText(), and subscribe(listener). Editor/formatting.ts implements it by editing the markdown source directly — toggling inline marks (**/*/`), line prefixes (- , 1. , - [ ] ), and ATX heading prefixes (#…) — and by reading active state from the Lezer syntax tree at the caret.

Formatting command catalog

formatting/commands.ts is the single, editor-agnostic catalog of formatting verbs (EDITOR_COMMANDS, EditorCommandId, ActiveFormatState, isCommandActive). It is metadata only — labels (i18n keys), icons, and grouping — with no dependency on any concrete editor. The toolbar and the active-state highlighting derive everything from this catalog; Editor/formatting.ts supplies how each verb is applied to the live CodeMirror document. To add a verb, add one entry here (and its field on ActiveFormatState).

Editor extensions

Editor/extensions.ts exports buildEditorExtensions(), which composes the CodeMirror extension set: @codemirror/lang-markdown (with GFM), line wrapping, a placeholder, the editor theme, the #tag/@mention decoration plugin, the #tag autocomplete, and an update listener that pushes document changes back to the reducer via onChange.

Editor/theme.ts defines the decorated-source look: a HighlightStyle over the Lezer markdown highlight tags (headings, strong, emphasis, code, links, quotes, markers) and an EditorView.theme. Colors come from CSS custom properties so light/dark themes just work. This is the editor's own styling — the read-only memo view styles itself separately via @/lib/markdownStyles.

Tags and mentions

#tag autocomplete and #tag/@mention decoration both reuse the shared grammar so the editor can't drift from the rest of the app:

  • Editor/tagMentionDecorations.ts is a ViewPlugin that scans the visible ranges and adds cm-memo-tag / cm-memo-mention marks, matching against TAG_RUN (@/utils/tag-grammar) and MENTION_RUN (@/utils/mention-grammar).
  • Editor/tagAutocomplete.ts is a CodeMirror autocompletion source for #tag, matching the in-progress token with TAG_CHAR_CLASS (@/utils/tag-grammar) and offering known tags (from useTagCounts).

Services

Pure TypeScript functions containing business logic. No React hooks, easy to test.

Components

Thin presentation components that dispatch actions and render UI.

Usage

import MemoEditor from "@/components/MemoEditor";

<MemoEditor
  memoName="memos/123"
  onConfirm={(name) => console.log('Saved:', name)}
  onCancel={() => console.log('Cancelled')}
/>

Testing

Services are pure functions — easy to unit test without React.

const state = mockEditorState();
const result = await memoService.save(state, { memoName: 'memos/123' });