8 file(s), +487 / -44 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +13 | -1 |
| messages/hi.json | Config | +13 | -1 |
| src/components/shared/editable-surface-preset.test.ts | Component | +108 | -4 |
| src/components/shared/editable-surface-preset.ts | Component | +104 | -28 |
| src/components/shared/editable-surface-toolbar.tsx | Component | +148 | -0 |
| src/components/shared/editable-surface.stories.tsx | Component | +63 | -0 |
| src/components/shared/editable-surface.test.tsx | Component | +12 | -0 |
| src/components/shared/editable-surface.tsx | Component | +26 | -10 |
Story-2024 laid the keystone: markdown installed under
EditableSurface and a preset seam, shipping only
plain. This story fills the seam — it makes
light and full reachable, adds the formatting
bubble-menu toolbar, and proves markdown round-trip identity per preset.
Adding a tier stayed one entry in resolvePreset,
never a new editor. The Brief is untouched — it stays on plain.
resolvePreset now resolves plain | light | full
to { extensions, contentType, toContent, toValue, toolbar }.
plain is unchanged (JSON doc, literal text, no toolbar).
light/full load and save as markdown
(contentType: "markdown") and carry a toolbar config.
light enables only bold, italic,
link; every block node and other mark is disabled.
full adds #/##/###
headings, -/1. lists, and >
blockquotes. A construct outside the subset can never be produced, so the
serializer can never emit something the round-trip won't preserve.
EditableSurfaceToolbar
(editable-surface-toolbar.tsx) — a @tiptap/react
BubbleMenu that floats over the selection with one icon button
per enabled action. It mounts only when the resolved strategy has a
toolbar (i.e. never for plain) and the surface is editable.
Each button toggles a mark/node via a focused command chain.
getMarkdown() to the
committed baseline. Toggling bold on a selection mutates the doc → the
markdown diverges → onDirtyChange(true) fires, even though no
characters were typed. That divergence is proved headlessly in the test.
plain builds its ProseMirror doc directly in
toContent. A markdown preset can't — parsing needs the editor's
markdown manager, which doesn't exist at useEditor init. The seam
that avoids re-plumbing the primitive: the strategy exposes a
contentType, and toContent for a markdown preset just
returns the raw string. Every setContent call site (init,
Escape-revert, external-value sync) passes
{ contentType: strategy.contentType }, so TipTap parses markdown
at set-time. toValue is getMarkdown() with a trailing
newline stripped, so an untouched value doesn't spuriously trip dirty/save
(the story's "modulo trailing-newline normalization").
Rather than pin brittle exact serializer output, each preset's round-trip test
loads a value, saves it (first), loads that and saves again
(second), and asserts second === first — a stable
fixpoint — plus that the preset's promised formatting survived. So
light preserves **bold**, *italic*, and
[text](url) but drops a stray # to inline text;
full preserves headings, lists, and blockquotes.
src/components/shared/editable-surface-preset.ts — light/full strategies, contentType, and per-preset toolbar config.src/components/shared/editable-surface-toolbar.tsx — new: the bubble-menu formatting toolbar.src/components/shared/editable-surface.tsx — threads contentType through setContent, renders the toolbar + block-formatting styles for formatting presets.src/components/shared/editable-surface-preset.test.ts — per-preset round-trip identity, toolbar config, and the format-trips-dirty mechanism.src/components/shared/editable-surface.test.tsx — plain carries no toolbar button.src/components/shared/editable-surface.stories.tsx — new LightPreset and FullPreset stories.messages/en.json / messages/hi.json — EditableSurface.toolbar action labels (both locales).
preset still defaults to "plain"; the Brief data
fields pass no preset and behave exactly as before. light/full
are reachable but not yet wired into any Brief field — that flip is a later
per-field prop change (RFD §6 step 4), out of this story's scope.
light — a one-prop change gated on the frame-contract story (RFD §3a).full consumer against the document entity (RFD §3b).window.prompt for the URL; a styled popover is a later UX pass. full's later tier (tables, footnotes) is out of scope per RFD §7.pnpm codegen run; no wire-contract change.
pnpm lint (clean, one pre-existing unrelated warning),
pnpm build (green, TypeScript passes), and the full
pnpm test suite (1009 tests, incl. the per-preset round-trip and
toolbar tests) all pass. The LightPreset / FullPreset
Storybook stories demonstrate the bubble-menu toolbar on selection, the dirty
flag tripping on a formatting-only edit, and the markdown value round-tripping.