Notion-style inline WYSIWYG edit mode#24
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces edit mode's chunky per-segment
<textarea>with a slim inline WYSIWYG editor. Clicking a slice makes its.slice-contentcontenteditable; 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). AcanSerializeguard 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-segmentCmd+/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 pluscanSerializeguardInlineEditor.ts— contenteditable session, mark wrap/unwrap via DOM Range, shortcuts, link insertion, Enter-split, arrow navigationFloatingFormatToolbar.ts— slim toolbar with active-mark reflectionModified
EditModeController.ts— orchestrates WYSIWYG vs raw routing, globalCmd+/andCmd+Shift+Fshortcuts, handle-menu items, slice-split, cross-slice arrow navindex.css— slim editor styles, gutter handle alignment per block type, edit-mode layout matches view mode exactlyStats
Test plan
🤖 Generated with Claude Code