Skip to content

Notion-style inline WYSIWYG edit mode#24

Merged
ptheofan merged 30 commits into
mainfrom
feat/edit-mode-inline-wysiwyg
May 16, 2026
Merged

Notion-style inline WYSIWYG edit mode#24
ptheofan merged 30 commits into
mainfrom
feat/edit-mode-inline-wysiwyg

Conversation

@ptheofan
Copy link
Copy Markdown
Owner

Summary

Replaces edit mode's chunky per-segment <textarea> with a slim inline WYSIWYG editor. Clicking a slice makes its .slice-content contenteditable; formatting shortcuts toggle real visual marks; on commit a scoped serializer walks the inline DOM and emits markdown for five marks (bold, italic, strikethrough, inline code, link). A canSerialize guard routes any segment containing unsupported inline content (raw <img>, <sup>, styled <span>, etc.) to a slim raw-markdown textarea instead of risking a mangled round-trip. A per-segment Cmd+/ toggle does the same on demand. A floating toolbar (hidden by default) provides button access to the same marks. Enter splits the current slice into two paragraphs Notion-style; Shift+Enter inserts a hard line break. Arrow Up/Down at the edge line crosses to the adjacent slice. Visual layout is preserved byte-identical with view mode — no horizontal shift, same vertical rhythm.

Components added

  • inlineMarkdownSerializer.ts — pure DOM→markdown serializer plus canSerialize guard
  • InlineEditor.ts — contenteditable session, mark wrap/unwrap via DOM Range, shortcuts, link insertion, Enter-split, arrow navigation
  • FloatingFormatToolbar.ts — slim toolbar with active-mark reflection

Modified

  • EditModeController.ts — orchestrates WYSIWYG vs raw routing, global Cmd+/ and Cmd+Shift+F shortcuts, handle-menu items, slice-split, cross-slice arrow nav
  • index.css — slim editor styles, gutter handle alignment per block type, edit-mode layout matches view mode exactly

Stats

  • 12 files changed, +3734 / −77
  • 4 new test files covering serializer, InlineEditor, FloatingFormatToolbar, EditModeController
  • 30 commits (full TDD; each task in the plan is its own commit)

Test plan

  • `pnpm typecheck` clean
  • `pnpm test` — all 560 tests pass across 29 files
  • `pnpm lint` clean
  • Manual: click a paragraph — becomes slim inline editable, no chunky box
  • Manual: `Cmd+B` / `Cmd+I` / `Cmd+Shift+X` / `Cmd+E` toggle marks; `Cmd+K` inserts a link
  • Manual: Enter splits at the caret into two paragraph slices, new one focused; Shift+Enter inserts a soft line break
  • Manual: Enter at end of slice inserts a new empty paragraph below (does not jump to next existing slice)
  • Manual: Enter at start of heading inserts empty paragraph above, heading prefix preserved
  • Manual: ArrowUp at top of slice / ArrowDown at bottom crosses to adjacent slice
  • Manual: `Cmd+/` toggles raw-markdown for the active segment; handle-menu "Edit as markdown" does the same
  • Manual: `Cmd+Shift+F` reveals the floating toolbar; starts hidden each session
  • Manual: A slice containing an inline image opens directly in the raw editor (canSerialize fallback)
  • Manual: Toggle edit mode on and off — no horizontal shift, same vertical rhythm between view and edit

🤖 Generated with Claude Code

ptheofan and others added 30 commits May 14, 2026 20:40
Notion-style per-segment WYSIWYG editor to replace the chunky textarea:
contenteditable surface, scoped inline-markdown serializer, floating
format toolbar, and a raw-markdown escape-hatch toggle.

Also gitignore the .superpowers/ brainstorm scratch directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 TDD tasks: scoped inline-markdown serializer, InlineEditor
contenteditable surface, FloatingFormatToolbar, and EditModeController
wiring (WYSIWYG/raw routing, shortcuts, handle-menu toggles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan deviation: isMarkActive and unwrapMark walk from a deepestStart
helper rather than range.commonAncestorContainer. selectNodeContents
puts commonAncestorContainer at the parent, above the mark element,
so the upward walk would miss the mark entirely. The helper descends
into the first selected child so the ancestor walk starts inside the
selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan deviation: findAncestorTag walks from deepestStart(range) rather
than range.commonAncestorContainer, matching the Task 5 fix. Without
this, selectAll over a sole anchor would miss the existing <a> and
the unwrap branch in applyLink would never fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces EditModeController's per-segment textarea with InlineEditor.
canSerialize routes unsupported inline content to startRawEdit (stubbed
here, wired in Task 10). commitActiveEdit now drives the InlineEditor;
applyInlineCommit reattaches the slice's block prefix and re-renders.

Plan deviation: SUPPORTED_TAGS now includes block wrappers (P, H1-H6,
UL/OL/LI, BLOCKQUOTE). markdown-it always wraps a slice's inline content
in one of these, so the prior set would reject every slice and fall
through to raw mode. serializeInline's default switch case already
descends into these wrappers transparently; the controller re-applies
the block prefix from slice.raw on commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pressing Enter inside the WYSIWYG editor now ends the current slice
at the caret and opens a new paragraph slice below with the after-caret
content, focused for typing. Shift+Enter inserts a <br> (soft line
break) inside the same slice.

InlineEditor: new onSplit callback. splitAtCaret extracts the after-caret
DOM, serializes both halves, ends the session (idempotent commit) and
hands the markdown to the controller. insertLineBreak handles
Shift+Enter.

EditModeController: splitActiveSlice re-applies the slice's block prefix
to the "before" half, inserts a new paragraph slice with the "after"
half, re-slices, re-renders synchronously (factored renderSlicesSync
so startEdit on the new slice runs before async plugin post-render),
and opens the new slice for editing.

Serializer: <br> now emits "  \n" (markdown hard-break syntax) instead
of bare "\n". The previous output round-tripped lossily because
markdown-it is configured with breaks:false, so soft breaks were
silently dropped on re-render.

Sidesteps a pre-existing MarkdownSlicer.reassemble bug that joins
slices with a single newline (collapsing paragraphs on re-slice) by
building the rawMarkdown directly with "\n\n" between slices in the
split path. The reassemble fix is out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pressing Enter at the end of a slice was navigating to the next existing
slice instead of inserting a new empty paragraph below the current one.

Root cause: splitActiveSlice fed the spliced slice array through
slicer.slice() to recompute line numbers. But markdown can't express an
empty paragraph — slicer.slice() collapsed the new empty slice away,
shifting the next existing slice into its position. startEdit on
this.slices[idx + 1] then opened that wrong slice.

Fix: skip the slicer round-trip. Recompute startLine/endLine manually
based on '\n\n'-joined raw content so the slice array remains
internally consistent (reassemble's startLine-sort still works for
later structural actions) without the empty-slice collapse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, Enter at the start of a heading/list/quote rewrote the
current slice's raw to '' (stripping its block prefix like '#' or
'- ') and put the content into a new plain paragraph below. So
'# Title' + Enter-at-start produced '# \n\nTitle' (empty heading +
plain paragraph) instead of '\n\n# Title' (empty paragraph above,
heading intact).

For paragraphs the bug was invisible because both shapes render
identically, but block-typed slices lost their type.

Fix: when beforeMd is empty and afterMd is non-empty, take the
"insert empty above" branch — splice an empty paragraph at the
current slice's position (pushing it to idx+1) and re-open editing
on the unchanged original. The current slice's raw, type, and block
prefix all stay intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ArrowUp on the first visual line of a slice commits the current edit
and opens the previous slice for editing, with the caret placed at
the end. ArrowDown on the last visual line does the symmetric thing
to the next slice, caret at the start. Within a slice, arrows fall
through to browser default (caret moves within the contenteditable).

InlineEditor: new onNavigate callback fired when the caret is on the
edge line and an adjacent move is requested. Edge detection uses the
caret's Range.getBoundingClientRect against the editor's element rect
with a 4px threshold; falls back to a zero-rect when the runtime
lacks Range.getBoundingClientRect (older jsdom), which makes both
edge checks pass — convenient for unit tests where the cross-slice
path is the interesting behaviour.

EditModeController: navigateFromSlice commits and opens the adjacent
slice, then placeCaretInActiveSlice positions the caret. No-ops at
slice boundaries (first slice + ArrowUp, last slice + ArrowDown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switching to edit mode was visibly shifting content horizontally and
changing the vertical rhythm between blocks. Three structural
differences caused this:

  1. .markdown-body.edit-mode added padding-left: 8px and .slice-content
     added padding: 2px 8px — net +8px horizontal indent of every line
     relative to view mode.
  2. .slice was display:flex, making .slice-content a flex item (BFC
     root). Margins inside it could not collapse with adjacent slices,
     so block-to-block spacing differed from view mode's collapsed
     margins.
  3. .slice-content > :first-child {margin-top: 0} + :last-child
     {margin-bottom: 0} stripped each slice's natural heading/paragraph
     margins, shortening the gap between blocks by ~20-40px per heading.

Fix: switch .slice to plain block layout so adjacent slices' margins
collapse like view mode, drop the slice-content padding and margin
resets, and float the handle absolutely in the negative-x gutter so it
no longer consumes flow space. Handle's vertical offset is tied to a
CSS variable (--slice-handle-top) overridden per slice type so it
still aligns with the first line of text inside headings (which sit
24px below the slice top due to their margin-top).

Net result: with no slice selected, view mode and edit mode are
visually identical except for the handle that fades in on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The handle button (24px tall) was offset 4px below the slice top
regardless of block type, leaving it visibly below the first text
line's center for paragraphs and inconsistently aligned across
heading levels (h6 was ~9px below center, h1 ~1.5px above).

Compute per-type --slice-handle-top so the button center matches
the first-line center: 0 for paragraphs/lists/blockquotes (body
line-height ~21px), and 19-30px for h6..h1 (each heading's
margin-top of 24px plus half the heading's line-height minus the
button's half-height). Values assume default font sizes; if those
change via preferences the alignment will drift but stay close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
<pre> has 16px top padding before the code text plus 85% font-size
with 1.45 line-height (~17px line), so the first code line centers at
y=24.5. Set --slice-handle-top: 13px so the 24px handle button
centers on that line instead of sitting near the top of the pre's
padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inset box-shadow drew a 2px blue indicator inside the content
box's left edge, where it overlapped the caret whenever the cursor
landed at the start of a line. Replace it with an absolutely
positioned ::before pseudo-element 8px to the left of the content —
the bar lives in the slice handle's gutter, the caret has clearance,
and the layout is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- border-radius on the editing indicator is now 2px, matching the bar
  width so its top/bottom render as semicircles instead of square ends.
- Default --slice-handle-top is -2px (was 0) so the 24px handle button
  centers on a paragraph's 21px first line (button center y=10, text
  center y=10.5). Heading offsets already accounted for line-heights
  per type; only the default needed tuning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 2px-wide bar with border-radius:2px renders 1px-effective curves
per corner (two corners on the same 2px edge can only sum to 2px),
which is below the visibility threshold at typical zoom. Widen to
3px so border-radius:3px gives 1.5px curves at the ends — visibly
pill-shaped. Bumped left from -8 to -9 so the gap from text stays
at 6px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3px width was thicker than wanted. Geometric limit: a 2px-wide
bar can only have 1px-radius corners (border-radius is clamped by
half the smaller edge), so the rounded caps will always be small.
Compensate by leaving 3px of space above and below the bar — the
caps stand visually distinct from the slice edges instead of sitting
flush, which makes the rounding readable even at small radius.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…caps)

The previous border-radius approach was geometrically capped at 1px
curves on a 2px-wide bar — too small to read as "rounded". Replace
with a barbell shape built from three composited mask layers: an SVG
upper-semicircle at the top, a matching lower-semicircle at the
bottom, and a 2px-wide vertical stripe between them. background-color
paints the whole element in --link-color; the masks carve out the
visible shape.

Net visual: a 2px-wide shaft with 5px-diameter round caps at top and
bottom. Caps stay round at any slice height (masks are fixed pixel
sizes, not stretched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the curve attempts (border-radius variations, SVG-mask barbell)
and keep the indicator as a plain 2px-wide bar in the gutter, 6px
from the text. Functional behaviour (cursor not concealed, layout
unchanged) is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ptheofan ptheofan merged commit 7f762bb into main May 16, 2026
4 checks passed
@ptheofan ptheofan deleted the feat/edit-mode-inline-wysiwyg branch May 16, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant