Skip to content

Add phase 2 inline extensions & clipboard#86

Merged
hackerwins merged 26 commits into
mainfrom
feat/docs-phase2
Mar 27, 2026
Merged

Add phase 2 inline extensions & clipboard#86
hackerwins merged 26 commits into
mainfrom
feat/docs-phase2

Conversation

@hackerwins

@hackerwins hackerwins commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Superscript/Subscript: data model, layout, rendering, keyboard shortcuts (Cmd+./Cmd+,), Yorkie serialization
  • Hyperlinks: href in InlineStyle, blue underline rendering, link popover (view/edit modes), Ctrl+K insert, auto-detect URLs on Space/Enter, Ctrl+Click to open
  • Clipboard: internal JSON copy/paste, HTML paste parsing, plain-text paste (Cmd+Shift+V), format painter (Cmd+Shift+C / Cmd+Alt+V)
  • Find & Replace: Cmd+F/H, search match highlighting in Canvas, replace/replace-all, find bar UI
  • UX polish: cursor-based link popover (not hover), inline URL input replacing window.prompt(), fixed positioning via Portal, horizontal scroll fixes, toolbar reorganization

Test plan

  • Verify superscript/subscript toggle via shortcuts and toolbar
  • Test link insertion via Ctrl+K and toolbar button
  • Verify link popover appears at cursor with edit/remove actions
  • Test copy/paste within editor preserves formatting
  • Test paste from external HTML sources
  • Test Cmd+F find and Cmd+H find & replace
  • Verify toolbar layout: undo,redo|style|B,I,U,color,bg|link|align,lists,indent
  • Run pnpm verify:fast — all tests pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Superscript/subscript formatting with shortcuts and correct rendering
    • Hyperlinks: auto-detect, insert/edit popover, toolbar button, and Ctrl/Cmd+Click to open
    • Enhanced clipboard: rich copy/paste preserving formatting, HTML→format conversion, and internal serialized clipboard MIME
    • Find & Replace: persistent overlay with match highlighting, navigation, replace/replace-all, case/regex options
    • Format painter (capture/apply inline style)
    • Toolbar: list toggle behavior updated (ordered vs unordered)

hackerwins and others added 20 commits March 26, 2026 08:49
Covers superscript/subscript, hyperlinks, clipboard operations,
and find & replace for the Docs word processor.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
14 tasks covering superscript/subscript, hyperlinks, clipboard,
and find & replace with TDD approach.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add superscript and subscript boolean fields to InlineStyle interface,
update inlineStylesEqual() to compare them, and enforce mutual exclusion
in applyStyleToBlock() so applying one clears the other.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Superscript and subscript runs now use 60% of the original font size
for text measurement and Canvas rendering. Superscript shifts the
baseline up by 40% of the original font size; subscript shifts it
down by 20%. Line height still uses the original (unreduced) font
size so lines with only super/subscript text are not clipped.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add Cmd+. / Cmd+, keyboard shortcuts for superscript/subscript toggle,
include both properties in clearFormatting, and add toolbar buttons
with tabler icons.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add href string property to InlineStyle, update inlineStylesEqual() to
compare it, and include it in clearFormatting(). Tests cover equality
detection, applying href to text, and removing it.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add EditorAPI methods (insertLink, removeLink, getLinkAtCursor,
onLinkRequest) for hyperlink management. Wire Cmd/Ctrl+K shortcut in
TextEditor to trigger a link request callback. Add link toolbar button
with window.prompt for URL entry. Serialize/deserialize href in Yorkie
doc store.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add hover detection for hyperlinks in the canvas editor that shows a
DOM popover with the URL, open/edit/remove buttons. Ctrl+Click (or
Cmd+Click on Mac) opens the link directly in a new tab.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
When the user types a space or presses Enter after a URL (http:// or
https://), the preceding token is automatically detected and converted
into a clickable hyperlink by applying an href inline style.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add clipboard serialization module with custom MIME type for preserving
formatting during copy/paste within the docs editor. Copy/cut now
serializes selected blocks with full inline styles and block properties.
Paste checks for internal format first, falling back to plain text.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add parseHtmlToInlines() to convert pasted HTML into formatted Inline
objects, mapping common tags (b/strong, i/em, u, s/del/strike, a) and
CSS properties (color, font-size, background-color) to InlineStyle.

Wire HTML paste into handlePaste with priority: internal JSON > HTML >
plain text. Add Cmd/Ctrl+Shift+V shortcut for explicit plain-text
paste (strips formatting). Extract insertPlainText() helper to share
logic between paste handler and shortcut.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add format painter shortcuts: Cmd+Shift+C captures inline styles at
the cursor into a style buffer; Cmd+Alt+V applies the buffered styles
to the current selection.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Add SearchOptions and SearchMatch types, implement Doc.searchText()
with case-insensitive default, case-sensitive option, and regex support.
Searches across all blocks using concatenated inline text.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Add search highlight state (searchMatches, activeMatchIndex) to the
editor and pass computed highlight rectangles to the canvas renderer.
Inactive matches render in yellow (#fff2a8), the active match in
orange (#f4a939). Highlights draw behind peer and local selections.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
FindReplaceState with next/previous/replace/replaceAll, Cmd+F/H
shortcuts, find bar React component with case-sensitive and regex
toggles.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Change link popover trigger from hover to cursor position
- Use position:fixed + Portal for reliable popover positioning
- Add inline URL input popover replacing window.prompt()
- Unify view/edit modes into single DocsLinkPopover component
- Fix horizontal scroll offset in mouse coordinate mapping
- Fix superscript/subscript cursor and selection misalignment
- Remove redundant Open button (URL text is clickable)
- Reorganize toolbar: undo,redo|style|B,I,U,color,bg|link|align,lists,indent

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai

coderabbitai Bot commented Mar 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds Phase 2 features: superscript/subscript and hyperlink inline styles; clipboard serialization and HTML→blocks parsing; URL auto-detection and link UI/commands; document-level search & replace; editor API and UI integrations; rendering/layout and Yorkie serialization updates; plus tests and roadmap/spec docs.

Changes

Cohort / File(s) Summary
Documentation & Roadmap
docs/design/docs-wordprocessor-roadmap.md, docs/superpowers/plans/2026-03-25-docs-block-types.md, docs/superpowers/specs/2026-03-26-docs-phase2-inline-clipboard-design.md, docs/tasks/active/20260326-docs-phase2-todo.md, docs/tasks/active/20260326-docs-phase2-lessons.md
Added Phase 2 completion status, detailed specs, implementation plans, and lessons learned for inline extensions, clipboard, find/replace, and block-type planning.
Model Types & Doc API
packages/docs/src/model/types.ts, packages/docs/src/model/document.ts
Extended InlineStyle with superscript?, subscript?, href?; updated inlineStylesEqual; added SearchOptions/SearchMatch; added Doc.updateBlockDirect, Doc.insertBlockAt, and Doc.searchText; enforce mutual exclusion of superscript/subscript when applying styles.
Clipboard & HTML→Block Parsing
packages/docs/src/view/clipboard.ts, packages/docs/src/view/url-detect.ts
New clipboard utilities: serializeBlocks/deserializeBlocks, WAFFLEDOCS_MIME, parseHtmlToBlocks (and deprecated parseHtmlToInlines); URL detection/normalization/safety helpers (detectUrlBeforeCursor, isSafeUrl, normalizeLinkUrl).
Find / Replace Core
packages/docs/src/view/find-replace.ts
New FindReplaceState class managing matches, active index, search options, search, navigation, replaceActive, and replaceAll (supports snapshot hook and re-search after edits).
Rendering & Layout
packages/docs/src/view/doc-canvas.ts, packages/docs/src/view/layout.ts, packages/docs/src/view/peer-cursor.ts, packages/docs/src/view/selection.ts
Render/measurement changes for superscript/subscript (60% font scaling and baseline offsets), link default styling (blue+underline unless overridden), search highlight rendering via searchHighlightRects/activeSearchIndex, and measurement adjustments across cursor/selection/peer-cursor logic.
Editor API & TextEditor
packages/docs/src/view/editor.ts, packages/docs/src/view/text-editor.ts
Expanded EditorAPI with link and find/search methods/events; editor-level search state and highlight computation; link insertion/removal/get-at-cursor; format-painter and shortcuts; copy/cut/paste flow (WAFFLEDOCS MIME → HTML → plain); URL auto-detect on typing; Ctrl/Cmd+Click safe link opening; coordinate/scroll fixes.
Yorkie Serialization
packages/frontend/src/app/docs/yorkie-doc-store.ts
Serialize/parse superscript, subscript, and href as Yorkie inline attributes (stringified).
Public Exports
packages/docs/src/index.ts
Exported FindReplaceState (value), types SearchMatch & SearchOptions, and URL helpers isSafeUrl/normalizeLinkUrl.
Frontend UI Components
packages/frontend/src/app/docs/docs-find-bar.tsx, packages/frontend/src/app/docs/docs-link-popover.tsx, packages/frontend/src/app/docs/docs-formatting-toolbar.tsx, packages/frontend/src/app/docs/docs-view.tsx
Added DocsFindBar (search/replace overlay), DocsLinkPopover (link view/edit popover), toolbar link button and list-toggle adjustments, and view wiring for find/link state with scroll-corrected hit-testing.
Tests
packages/docs/test/model/document.test.ts, packages/docs/test/model/types.test.ts, packages/docs/test/view/clipboard.test.ts, packages/docs/test/view/find-replace.test.ts, packages/docs/test/view/layout.test.ts, packages/docs/test/view/url-detect.test.ts
New Vitest suites covering superscript/subscript mutual exclusion, href behavior, searchText (case/regex), clipboard JSON round-trip and HTML parsing, find/replace navigation and replace ops, layout measurement for super/sub, and URL detection.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(220,220,255,0.5)
    actor User
    participant EditorAPI
    participant FindReplaceState
    participant Doc
    participant DocCanvas
    end

    User->>EditorAPI: onFindRequest()
    EditorAPI->>FindReplaceState: create/get(doc)
    User->>FindReplaceState: search(query, options)
    FindReplaceState->>Doc: searchText(query, options)
    Doc-->>FindReplaceState: SearchMatch[]
    FindReplaceState->>EditorAPI: setSearchMatches(matches, activeIndex)
    EditorAPI->>DocCanvas: render(..., searchHighlightRects, activeSearchIndex)
    DocCanvas-->>User: highlighted matches rendered
Loading
sequenceDiagram
    rect rgba(220,255,220,0.5)
    actor User
    participant TextEditor
    participant EditorAPI
    participant Doc
    participant DocsLinkPopover
    participant DocCanvas
    end

    User->>TextEditor: type URL or press (mod+K)
    TextEditor->>EditorAPI: requestLink()/insertLink(url)
    EditorAPI->>Doc: applyInlineStyle({href: url}, selection)
    Doc-->>EditorAPI: updated inlines
    EditorAPI->>DocCanvas: render()
    DocCanvas-->>User: link rendered
    User->>TextEditor: move cursor into link
    TextEditor->>EditorAPI: onCursorLinkChange({href, rect})
    EditorAPI->>DocsLinkPopover: show(href, rect)
Loading
sequenceDiagram
    rect rgba(255,220,220,0.5)
    actor User
    participant TextEditor
    participant Clipboard
    participant Doc
    participant DocCanvas
    end

    User->>TextEditor: Ctrl/Cmd+C
    TextEditor->>Clipboard: serializeBlocks(blocks) (WAFFLEDOCS_MIME + text/plain)
    User->>TextEditor: Ctrl/Cmd+V
    TextEditor->>Clipboard: read WAFFLEDOCS_MIME?
    alt WAFFLEDOCS available
        Clipboard-->>TextEditor: blocks
        TextEditor->>Doc: insertBlocks(blocks)
    else HTML available
        Clipboard-->>TextEditor: html
        TextEditor->>Clipboard: parseHtmlToBlocks(html)
        TextEditor->>Doc: insertBlocks(parsed)
    else plain text
        TextEditor->>Doc: insertPlainText(text)
    end
    Doc-->>DocCanvas: render updated content
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through Phase Two’s code, light-footed and spry,

Superscripts leaping, subs snug and shy,
Links that sparkle, paste that keeps flair,
Find-and-replace chasing matches everywhere,
A rabbit applauds — the docs now fly!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add phase 2 inline extensions & clipboard' is a generic description that does not clearly convey the specific changes. It lacks detail about what features are being implemented (superscript, subscript, hyperlinks, find & replace, etc.).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/docs-phase2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

hackerwins and others added 2 commits March 27, 2026 19:40
Resolve conflicts in editor.ts (keep link methods + optional chaining)
and text-editor.ts (keep private field refactor + callback properties).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Add null checks for textEditor (nullable after main refactor)
- Track shift key state for ClipboardEvent paste handler
- Fix mock ctx type assertions in layout tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@github-actions

github-actions Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 105.6s

Lane Status Duration
sheets:build ✅ pass 13.0s
docs:build ✅ pass 5.8s
verify:fast ✅ pass 48.7s
frontend:build ✅ pass 14.9s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.3s
cli:build ✅ pass 1.8s
verify:entropy ✅ pass 16.7s

Verification: verify:integration

Result: ✅ PASS

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (3)
packages/docs/test/model/types.test.ts (1)

135-143: Consider adding a test for differing href values.

The current test only checks href present vs absent. Adding a case for two different href strings would strengthen coverage.

💡 Suggested additional test case
   it('should detect href difference', () => {
     expect(inlineStylesEqual({ href: 'https://example.com' }, {})).toBe(false);
     expect(
       inlineStylesEqual(
         { href: 'https://example.com' },
         { href: 'https://example.com' },
       ),
     ).toBe(true);
+    expect(
+      inlineStylesEqual(
+        { href: 'https://example.com' },
+        { href: 'https://other.com' },
+      ),
+    ).toBe(false);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/test/model/types.test.ts` around lines 135 - 143, Add a test
that asserts inlineStylesEqual returns false for two different href values:
inside the existing "should detect href difference" test (or as a new it block)
call inlineStylesEqual({ href: 'https://example.com' }, { href:
'https://other.com' }) and expect(false); this uses the inlineStylesEqual
function to verify differing href strings are considered unequal.
packages/docs/src/view/layout.ts (1)

256-259: Extract the 0.6 superscript/subscript scale factor to a shared constant.

The 0.6 multiplier for superscript/subscript font scaling is duplicated across multiple files (layout.ts, selection.ts, peer-cursor.ts, doc-canvas.ts). Defining a constant like SUPERSCRIPT_SCALE = 0.6 in theme.ts would centralize this value and reduce maintenance burden if it needs adjustment.

♻️ Suggested constant extraction

In packages/docs/src/view/theme.ts:

export const SUPERSCRIPT_SCALE = 0.6;

Then in this file:

+import { Theme, buildFont, ptToPx, SUPERSCRIPT_SCALE } from './theme.js';
 // ...
 const charFontSize = isSuperOrSub
-  ? (seg.style.fontSize ?? Theme.defaultFontSize) * 0.6
+  ? (seg.style.fontSize ?? Theme.defaultFontSize) * SUPERSCRIPT_SCALE
   : seg.style.fontSize;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/layout.ts` around lines 256 - 259, Extract the magic
0.6 multiplier into a single exported constant (e.g., SUPERSCRIPT_SCALE = 0.6)
in your theme module and import it here; replace the inline 0.6 used when
computing charFontSize (the ternary using seg.style.superscript ||
seg.style.subscript and Theme.defaultFontSize) with SUPERSCRIPT_SCALE so all
files (layout.ts, selection.ts, peer-cursor.ts, doc-canvas.ts) can reuse the
same value.
packages/docs/src/model/document.ts (1)

331-345: Add JSDoc comments documenting the undo-boundary requirement for these low-level helpers.

updateBlockDirect() and insertBlockAt() mutate the store without snapshotting internally. While they're currently safe (private methods called only from handlePaste(), which always establishes the boundary first), their undo-boundary contract should be explicit in the method signatures to prevent misuse if they're later promoted to public APIs or called from new contexts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/model/document.ts` around lines 331 - 345, The methods
updateBlockDirect and insertBlockAt perform direct mutations on this.store
without creating an undo snapshot, so callers must establish an undo boundary
before invoking them; add JSDoc above each method (updateBlockDirect(blockId:
string, block: Block): void and insertBlockAt(index: number, block: Block):
void) explicitly stating that they do not snapshot state, mutate the store
in-place, and require the caller to create an undo boundary (e.g., via the
surrounding handlePaste logic or a start/commit undo API) before calling to
avoid breaking undo/redo behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/tasks/active/20260326-docs-phase2-todo.md`:
- Around line 1-11: The docs task is missing its paired lessons file; create
docs/tasks/active/20260326-docs-phase2-lessons.md alongside the existing
20260326-docs-phase2-todo.md, include a matching title/metadata referencing the
Todo entry, a short summary of implementation learnings, a lessons checklist
template (what went well, blockers, follow-ups), and links back to the spec
docs/superpowers/specs/2026-03-26-docs-phase2-inline-clipboard-design.md so the
task and lessons remain linked for tracking and future retrospectives.

In `@packages/docs/src/model/document.ts`:
- Around line 351-368: The searchText method needs guards for
invalid/zero-length regexes: wrap the RegExp(query, flags) call in a try-catch
and return [] on construction errors to avoid throwing for inputs like "("; also
before the while ((match = pattern.exec(text)) !== null) loop reset
pattern.lastIndex = 0 and detect zero-length matches (match[0].length === 0) and
advance pattern.lastIndex (or break/skip) to prevent an infinite loop; update
references to pattern, pattern.exec, and searchText to implement these checks.

In `@packages/docs/src/view/clipboard.ts`:
- Around line 58-76: The node-walking logic currently only returns inline text,
so block-level elements and <br> are lost; update the walker in clipboard.ts
(the code handling node, Element, tag, TAG_STYLE_MAP and InlineStyle) to emit
explicit separators for block boundaries: when tag === 'br' emit a newline text
node (or an inline token representing a line break), and for block tags (p, div,
li, ul/ol boundaries, headings, blockquote, pre, etc.) ensure you insert a
paragraph/line-separator (e.g., '\n' or a sentinel token) before/after the block
so paragraphs and list items are preserved; also apply the same change around
the other occurrence noted (lines ~106-108) so both traversal branches preserve
these separators. Ensure callers still receive the flat inline list but with
separator tokens that represent original HTML boundaries.
- Around line 86-90: The font-size parser in clipboard.ts currently only matches
"NNpx" and assigns the raw number to style.fontSize, which ignores document
units (points) and drops "pt" values; update the logic that reads
el.style.fontSize so it accepts both "px" and "pt" (e.g. regex capturing value
and unit), then when unit === "px" convert to the model's point unit (points =
px * 72 / 96, i.e. px * 0.75) and when unit === "pt" use the parsed value
directly, and finally assign the computed point value to style.fontSize
(referencing el.style.fontSize and the InlineStyle variable style.fontSize).
- Around line 14-18: The deserializeBlocks function currently calls JSON.parse
directly which throws on malformed input; wrap the parse in a try-catch inside
deserializeBlocks, validate that the parsed object matches the expected
ClipboardPayload shape (e.g., has a numeric version and an array blocks), and if
parsing or validation fails return an empty array (and optionally log the error)
so callers like handlePaste can gracefully fall back instead of crashing.

In `@packages/docs/src/view/editor.ts`:
- Around line 710-727: The removeLink handler currently only clears href on the
single inline under the cursor (in removeLink), leaving adjacent runs of the
same logical link still clickable; change it to expand the selection left and
right across adjacent inlines in the same block that share the same
inline.style.href value before calling docStore.snapshot() and
doc.applyInlineStyle(). Specifically, locate the block via
doc.document.blocks.find(...) and, once you identify the inline under the cursor
(where inline.style.href is truthy), walk backwards and forwards from that
inline index while neighboring inlines have the exact same href, compute the
expanded anchor and focus offsets (use the block.id and cumulative offsets),
then call docStore.snapshot(), doc.applyInlineStyle(expandedRange, { href:
undefined }), followed by markDirty(block.id) and render().
- Around line 309-320: The search highlight path builds pseudo-selection
endpoints from bare {blockId, offset} and therefore needs explicit wrap affinity
to avoid painting on the wrong visual line; modify the mapping that produces
searchHighlightRects so each endpoint includes a lineAffinity (e.g.,
startAffinity/endAffinity or anchor/focus.affinity) derived the same way
selection handling does, then pass those affinity-aware endpoint objects into
computeSelectionRects (the call site using searchMatches, paginatedLayout,
layout, docCanvas.getContext(), canvasWidth) so computeSelectionRects receives
and uses lineAffinity for wrap-boundary disambiguation.

In `@packages/docs/src/view/find-replace.ts`:
- Around line 19-23: search() currently always sets activeIndex to 0 which
causes repeated replace operations to jump back to the first match; update
search(query, options?) so it preserves the existing activeIndex when the query
(and options) are unchanged: if this.query === query (and shallow-equal
this.options to options) keep this.activeIndex as-is (clamped to the new matches
length), otherwise set activeIndex = this.matches.length > 0 ? 0 : -1; use the
existing this.matches/doc.searchText and ensure replaceActive() can rely on
preserved activeIndex to advance to the next match instead of restarting at 0.
- Around line 46-57: The replace operations (replaceActive and the replace-all
path) mutate the Doc directly via this.doc.deleteText / this.doc.insertText
without establishing an undo boundary, so create an undo snapshot/transaction on
this.doc before performing the delete/insert and close/commit it after (i.e.,
call your project's Doc undo API such as begin/commitSnapshot or
createSnapshot/pushUndoBoundary around the deleteText/insertText calls in
replaceActive and the replace-all code paths), then call this.search(this.query,
this.options) as before; this ensures each replace or replace-all is a single
undoable operation.

In `@packages/docs/src/view/text-editor.ts`:
- Around line 1413-1443: The paste logic preserves inlines but drops block-level
metadata for the first and last pasted blocks; to fix it, when handling the head
and tail merges in the multi-block branch (after splitBlock and before calling
updateBlockDirect), copy the block-level fields (type, style, headingLevel,
listKind, listLevel, any other block-level properties present on Block) from
blocks[0] into headBlock and from blocks[blocks.length-1] into tailBlock (while
keeping head/tail ids and merging/overwriting their style/inlines as currently
done). Use existing helpers (spliceInlinesAt, getBlock/getBlockIndex,
updateBlockDirect) and keep the current middle-block creation
(generateBlockId/insertBlockAt) as-is; just set the metadata on headBlock and
tailBlock before the updateBlockDirect calls so two-block pastes also preserve
block-level formatting.
- Around line 509-516: The format-painter branch mutates inline styles directly
without creating an undo snapshot or marking affected layout as dirty; fix by
calling this.saveSnapshot() before this.doc.applyInlineStyle(...) and after
applying the style call the editor/document dirtying API to invalidate layout
for the affected selection (e.g., this.doc.markRangeDirty(this.selection.range)
or this.markBlocksDirtyForRange(this.selection.range) depending on the existing
API), then call this.requestRender(); this ensures the change is undoable and
forces remeasurement/re-render.
- Around line 519-529: The current inline
navigator.clipboard.readText().then(...) in the key handling block can reject
and leaves an unhandled promise; extract the logic into an async helper (e.g.,
private async pastePlainTextFromClipboard()) that wraps await
navigator.clipboard.readText() in try/catch, keeps the existing sequence
(this.saveSnapshot(), this.deleteSelection(), this.insertPlainText(text),
this.selection.setRange(null), this.requestRender()) and call that helper from
the key handler instead of using .then(); this replaces the .then() chain with
async/await and ensures failures are caught (optionally log or silently ignore
in the catch).
- Around line 624-629: The Ctrl/Cmd+click handler currently opens raw hrefs from
getLinkHrefAtMouse without validating the scheme; add a safety check (e.g.,
implement a private isSafeLinkHref(href: string) that constructs a URL relative
to window.location and only allows protocols 'http:', 'https:', 'mailto:',
'tel:') and call it before calling window.open in the Ctrl/Cmd click branch
inside the click handler where getLinkHrefAtMouse is used; if isSafeLinkHref
returns false, do not call window.open (and keep the default behavior).

In `@packages/docs/src/view/url-detect.ts`:
- Around line 12-20: The detectUrlBeforeCursor function is currently returning
the whole whitespace-delimited token (variable token) as the URL; instead locate
the scheme inside that token (search for "http://" or "https://") and extract
only the substring from the scheme start to the URL end, trimming trailing
sentence punctuation/closing delimiters (e.g., '.', ',', ';', ':', ')', ']',
'}', '"', ''') but not internal punctuation; compute and return start and end
indices based on the scheme start and trimmed end (instead of cursor token
boundaries) so hrefs like "https://example.com)" become "https://example.com";
add a regression test covering parenthesized and punctuation-terminated URLs to
verify correct trimming.

In `@packages/frontend/src/app/docs/docs-find-bar.tsx`:
- Around line 39-46: The component currently constructs mutable FindReplaceState
with stateRef.current = new FindReplaceState(editor.getDoc()) and allows
replace/replace-all to mutate the Doc directly; instead, keep FindReplaceState
read-only (do not call its mutation methods) and route all replacement actions
through the store-backed editor command layer (e.g., dispatch the store action
or call the editor/store replace methods you already have) so replacements go
through undo/sync paths; update the handlers that call replace/replace-all to
use the store/editor command API rather than FindReplaceState's mutators and
ensure stateRef.current is only used for read-only search metadata.
- Around line 62-76: Wrap the call to state.search inside runSearch in a
try-catch to guard regex mode against invalid patterns: call state.search(q, {
caseSensitive, useRegex }) inside try, call syncHighlights() on success, and in
catch clear highlights and set an inline validation/error state (e.g.
setRegexError or existing validation state) with the regex error message so the
component remains interactive instead of throwing; also ensure the useEffect
that re-runs runSearch([caseSensitive, useRegex]) will respect/clear that
validation state when options change or when the query becomes valid.

In `@packages/frontend/src/app/docs/docs-link-popover.tsx`:
- Around line 128-133: The code currently persists editUrl.trim() verbatim in
handleApply (and the similar handler around insertLink at lines ~173-179);
update these handlers to run the user-entered URL through a shared allow-listed
normalizer/sanitizer before calling editor.insertLink and before persisting or
rendering links. Implement or reuse a utility (e.g., normalizeSafeUrl(url):
string|null) that enforces allowed schemes (https, http, mailto, tel, etc.),
adds missing protocol (https) for bare hostnames if desired, and returns null
for disallowed schemes (javascript:, data:, etc.), then: 1) call
normalizeSafeUrl(editUrl.trim()) and abort with a user-safe error if it returns
null, 2) pass the normalized result to editor.insertLink and any persistence
paths, and 3) ensure the same normalizer is used by the HTML-paste path to
sanitize pasted <a href> values before insertion.

---

Nitpick comments:
In `@packages/docs/src/model/document.ts`:
- Around line 331-345: The methods updateBlockDirect and insertBlockAt perform
direct mutations on this.store without creating an undo snapshot, so callers
must establish an undo boundary before invoking them; add JSDoc above each
method (updateBlockDirect(blockId: string, block: Block): void and
insertBlockAt(index: number, block: Block): void) explicitly stating that they
do not snapshot state, mutate the store in-place, and require the caller to
create an undo boundary (e.g., via the surrounding handlePaste logic or a
start/commit undo API) before calling to avoid breaking undo/redo behavior.

In `@packages/docs/src/view/layout.ts`:
- Around line 256-259: Extract the magic 0.6 multiplier into a single exported
constant (e.g., SUPERSCRIPT_SCALE = 0.6) in your theme module and import it
here; replace the inline 0.6 used when computing charFontSize (the ternary using
seg.style.superscript || seg.style.subscript and Theme.defaultFontSize) with
SUPERSCRIPT_SCALE so all files (layout.ts, selection.ts, peer-cursor.ts,
doc-canvas.ts) can reuse the same value.

In `@packages/docs/test/model/types.test.ts`:
- Around line 135-143: Add a test that asserts inlineStylesEqual returns false
for two different href values: inside the existing "should detect href
difference" test (or as a new it block) call inlineStylesEqual({ href:
'https://example.com' }, { href: 'https://other.com' }) and expect(false); this
uses the inlineStylesEqual function to verify differing href strings are
considered unequal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d8606fd7-9d58-4c96-9906-e80040a8bd3b

📥 Commits

Reviewing files that changed from the base of the PR and between e8e6f4e and 313cc99.

📒 Files selected for processing (27)
  • docs/design/docs-wordprocessor-roadmap.md
  • docs/superpowers/plans/2026-03-25-docs-block-types.md
  • docs/superpowers/specs/2026-03-26-docs-phase2-inline-clipboard-design.md
  • docs/tasks/active/20260326-docs-phase2-todo.md
  • packages/docs/src/index.ts
  • packages/docs/src/model/document.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/view/clipboard.ts
  • packages/docs/src/view/doc-canvas.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/find-replace.ts
  • packages/docs/src/view/layout.ts
  • packages/docs/src/view/peer-cursor.ts
  • packages/docs/src/view/selection.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/src/view/url-detect.ts
  • packages/docs/test/model/document.test.ts
  • packages/docs/test/model/types.test.ts
  • packages/docs/test/view/clipboard.test.ts
  • packages/docs/test/view/find-replace.test.ts
  • packages/docs/test/view/layout.test.ts
  • packages/docs/test/view/url-detect.test.ts
  • packages/frontend/src/app/docs/docs-find-bar.tsx
  • packages/frontend/src/app/docs/docs-formatting-toolbar.tsx
  • packages/frontend/src/app/docs/docs-link-popover.tsx
  • packages/frontend/src/app/docs/docs-view.tsx
  • packages/frontend/src/app/docs/yorkie-doc-store.ts

Comment on lines +1 to +11
# Docs Phase 2: Inline Extensions & Clipboard — 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:** Add superscript/subscript, hyperlinks, clipboard operations, and find & replace to the Docs word processor.

**Architecture:** Extend the existing `InlineStyle` type with three new optional fields (`superscript`, `subscript`, `href`). Each feature follows the same vertical slice: data model → layout/rendering → shortcuts/toolbar → Yorkie serialization. Clipboard and Find & Replace are independent modules layered on top.

**Tech Stack:** TypeScript, Vitest, Canvas API, Yorkie CRDT

**Spec:** `docs/superpowers/specs/2026-03-26-docs-phase2-inline-clipboard-design.md`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd '20260326-docs-phase2-(todo|lessons)\.md' docs/tasks/active -a

Repository: wafflebase/wafflebase

Length of output: 130


Create the paired -lessons file.

This non-trivial task requires docs/tasks/active/20260326-docs-phase2-lessons.md to exist alongside the -todo file for tracking implementation learnings, per coding guidelines.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/tasks/active/20260326-docs-phase2-todo.md` around lines 1 - 11, The docs
task is missing its paired lessons file; create
docs/tasks/active/20260326-docs-phase2-lessons.md alongside the existing
20260326-docs-phase2-todo.md, include a matching title/metadata referencing the
Todo entry, a short summary of implementation learnings, a lessons checklist
template (what went well, blockers, follow-ups), and links back to the spec
docs/superpowers/specs/2026-03-26-docs-phase2-inline-clipboard-design.md so the
task and lessons remain linked for tracking and future retrospectives.

Comment thread packages/docs/src/model/document.ts
Comment thread packages/docs/src/view/clipboard.ts
Comment thread packages/docs/src/view/clipboard.ts
Comment thread packages/docs/src/view/clipboard.ts Outdated
Comment thread packages/docs/src/view/text-editor.ts
Comment thread packages/docs/src/view/url-detect.ts Outdated
Comment on lines +12 to +20
// Scan backward from cursorOffset to find word start
let start = cursorOffset;
while (start > 0 && text[start - 1] !== ' ' && text[start - 1] !== '\n') {
start--;
}

const token = text.slice(start, cursorOffset);
if (/^https?:\/\/.+/.test(token)) {
return { start, end: cursorOffset, url: token };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t treat the entire whitespace token as the URL.

Right now detectUrlBeforeCursor() promotes the whole token. That stores broken hrefs for very common inputs like https://example.com) or https://example.com., and it misses parenthesized/quoted URLs because the scheme is no longer at the token start. Please extract the URL substring itself rather than assuming the whole token is link text, and add a regression around sentence-ending punctuation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/url-detect.ts` around lines 12 - 20, The
detectUrlBeforeCursor function is currently returning the whole
whitespace-delimited token (variable token) as the URL; instead locate the
scheme inside that token (search for "http://" or "https://") and extract only
the substring from the scheme start to the URL end, trimming trailing sentence
punctuation/closing delimiters (e.g., '.', ',', ';', ':', ')', ']', '}', '"',
''') but not internal punctuation; compute and return start and end indices
based on the scheme start and trimmed end (instead of cursor token boundaries)
so hrefs like "https://example.com)" become "https://example.com"; add a
regression test covering parenthesized and punctuation-terminated URLs to verify
correct trimming.

Comment on lines +39 to +46
// Build / rebuild FindReplaceState when editor changes
useEffect(() => {
if (!editor) {
stateRef.current = null;
return;
}
stateRef.current = new FindReplaceState(editor.getDoc());
}, [editor]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route replace mutations through the editor/store layer.

This component builds FindReplaceState from editor.getDoc(), and the replacement methods behind it mutate that Doc directly. That sidesteps the store-backed mutation path, so replace/replace-all can miss undo snapshots and Yorkie persistence/sync. Please keep the find state read-only and perform replacements through store-backed editor commands instead.

Also applies to: 97-110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/src/app/docs/docs-find-bar.tsx` around lines 39 - 46, The
component currently constructs mutable FindReplaceState with stateRef.current =
new FindReplaceState(editor.getDoc()) and allows replace/replace-all to mutate
the Doc directly; instead, keep FindReplaceState read-only (do not call its
mutation methods) and route all replacement actions through the store-backed
editor command layer (e.g., dispatch the store action or call the editor/store
replace methods you already have) so replacements go through undo/sync paths;
update the handlers that call replace/replace-all to use the store/editor
command API rather than FindReplaceState's mutators and ensure stateRef.current
is only used for read-only search metadata.

Comment thread packages/frontend/src/app/docs/docs-find-bar.tsx
Comment thread packages/frontend/src/app/docs/docs-link-popover.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
packages/docs/src/view/editor.ts (2)

310-320: ⚠️ Potential issue | 🟡 Minor

Search highlights still drop wrap-boundary affinity.

These synthetic ranges are still built from bare { blockId, offset } endpoints. A match that starts or ends exactly at a visual wrap boundary can paint on the wrong line because computeSelectionRects() has no lineAffinity to disambiguate it. Based on learnings In packages/docs/src/view/*, when computing selection boundaries and wrap-aware cursor/navigation behavior, include and consistently thread lineAffinity through the relevant selection/text-editor helpers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/editor.ts` around lines 310 - 320, The search
highlight ranges are built with plain endpoints (blockId/offset) which lose
wrap-boundary affinity and can render on the wrong line; update the construction
of the synthetic match endpoints used for computeSelectionRects (where
searchHighlightRects is populated from searchMatches) to include a lineAffinity
property on both anchor and focus (e.g., { blockId, offset, lineAffinity }) and
ensure computeSelectionRects and any intermediate helpers accept and thread this
lineAffinity through so wrap-aware rendering/disambiguation is preserved for
boundary matches.

713-730: ⚠️ Potential issue | 🟠 Major

Still only unlinks the current inline segment.

A single logical link can span adjacent inlines once formatting splits the text. This still clears href only on the inline under the cursor, so “Remove link” can leave the rest of the same link clickable. Expand left/right across contiguous inlines with the same href before calling applyInlineStyle().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/editor.ts` around lines 713 - 730, The removeLink
handler currently clears href only for the inline under the cursor; broaden the
range by scanning adjacent inlines with the same href before calling
applyInlineStyle. Locate removeLink and, when you detect inline.style.href,
record the inline's href, then walk left from the current inline index while
previousInline.style.href === href (accumulating start offset) and walk right
while nextInline.style.href === href (accumulating end offset) to compute the
full anchor/focus offsets, then call docStore.snapshot();
doc.applyInlineStyle(range, { href: undefined }); markDirty(block.id); render();
so the entire contiguous linked segment is unlinked.
🧹 Nitpick comments (1)
packages/docs/src/view/editor.ts (1)

782-790: Search-state updates can repaint without recomputing layout.

searchMatches and activeMatchIndex only affect highlight paint, but both setters call render(). Switching these to renderPaintOnly() avoids a full layout pass on every find-bar keystroke and match navigation.

♻️ Suggested refactor
     setSearchMatches: (matches: SearchMatch[], activeIndex: number) => {
       searchMatches = matches;
       activeMatchIndex = activeIndex;
-      render();
+      renderPaintOnly();
     },
     clearSearchMatches: () => {
       searchMatches = [];
       activeMatchIndex = -1;
-      render();
+      renderPaintOnly();
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/editor.ts` around lines 782 - 790, The setters
setSearchMatches and clearSearchMatches currently call render() causing a full
layout on every search change; change both to call renderPaintOnly() instead
(since searchMatches and activeMatchIndex only affect paint/highlights) so
updates trigger paint-only repaints; update references in setSearchMatches and
clearSearchMatches to use renderPaintOnly() while leaving searchMatches and
activeMatchIndex assignment logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/docs/src/view/editor.ts`:
- Around line 736-749: getLinkAtCursor currently uses "if
(cursor.position.offset <= inlineEnd)" which treats a caret at an inline
boundary as part of the previous inline; change the boundary check to a
half-open range so the caret at the first character of the next inline resolves
to that inline. Update the condition in getLinkAtCursor to check
"cursor.position.offset >= pos && cursor.position.offset < inlineEnd" (or at
minimum "cursor.position.offset < inlineEnd") when iterating block.inlines so
the function returns the correct inline.style.href for boundary positions.

In `@packages/frontend/src/app/docs/docs-view.tsx`:
- Around line 335-345: The DocsLinkPopover receives editorRef.current which is
assigned after initialize() but doesn't trigger a re-render, so its
useEffect([editor]) (and the onCursorLinkChange subscription) is missed; make
the prop reactive by introducing a state variable (e.g., editorState) and
updating it when you set editorRef.current inside initialize() (or after the ref
write), then pass that state (not editorRef.current) to DocsLinkPopover;
alternatively you can force a remount by giving DocsLinkPopover a key tied to
editorRef.current, but the preferred fix is to setEditor(editorRef.current) in
initialize() and use editorState in the JSX so useEffect([editor]) inside
DocsLinkPopover sees the editor on first load.

---

Duplicate comments:
In `@packages/docs/src/view/editor.ts`:
- Around line 310-320: The search highlight ranges are built with plain
endpoints (blockId/offset) which lose wrap-boundary affinity and can render on
the wrong line; update the construction of the synthetic match endpoints used
for computeSelectionRects (where searchHighlightRects is populated from
searchMatches) to include a lineAffinity property on both anchor and focus
(e.g., { blockId, offset, lineAffinity }) and ensure computeSelectionRects and
any intermediate helpers accept and thread this lineAffinity through so
wrap-aware rendering/disambiguation is preserved for boundary matches.
- Around line 713-730: The removeLink handler currently clears href only for the
inline under the cursor; broaden the range by scanning adjacent inlines with the
same href before calling applyInlineStyle. Locate removeLink and, when you
detect inline.style.href, record the inline's href, then walk left from the
current inline index while previousInline.style.href === href (accumulating
start offset) and walk right while nextInline.style.href === href (accumulating
end offset) to compute the full anchor/focus offsets, then call
docStore.snapshot(); doc.applyInlineStyle(range, { href: undefined });
markDirty(block.id); render(); so the entire contiguous linked segment is
unlinked.

---

Nitpick comments:
In `@packages/docs/src/view/editor.ts`:
- Around line 782-790: The setters setSearchMatches and clearSearchMatches
currently call render() causing a full layout on every search change; change
both to call renderPaintOnly() instead (since searchMatches and activeMatchIndex
only affect paint/highlights) so updates trigger paint-only repaints; update
references in setSearchMatches and clearSearchMatches to use renderPaintOnly()
while leaving searchMatches and activeMatchIndex assignment logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6828e31a-3256-482d-9edf-8b27629d6b9b

📥 Commits

Reviewing files that changed from the base of the PR and between 313cc99 and 09fa533.

📒 Files selected for processing (5)
  • packages/docs/src/view/doc-canvas.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/test/view/layout.test.ts
  • packages/frontend/src/app/docs/docs-view.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/docs/test/view/layout.test.ts
  • packages/docs/src/view/text-editor.ts

Comment thread packages/docs/src/view/editor.ts
Comment thread packages/frontend/src/app/docs/docs-view.tsx
- Scroll active search match into view on Next/Previous navigation
- Move cursor and select the active match text on Esc close
- Matches Google Docs find behavior

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Security (Critical):
- Add URL scheme validation (isSafeUrl) before window.open
- Add URL normalization (normalizeLinkUrl) for link insertion
- Sanitize href in link popover view mode

Robustness (Major):
- Guard regex search against invalid patterns and zero-length matches
- Add try-catch to deserializeBlocks for malformed JSON
- Add undo snapshot to find-replace operations
- Preserve activeIndex after replace instead of resetting to 0
- Add snapshot and dirty marking to format painter
- Handle clipboard.readText() rejection with async/await
- Expand removeLink across adjacent inlines with same href
- Fix getLinkAtCursor boundary detection

UI:
- Fix find bar scroll-with-content via Portal + position:fixed
- Unify find bar theme with shadcn design tokens

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Preserve HTML block separators (<br>, <p>, <div>, etc.) during paste
  by emitting newline tokens and splitting into multiple blocks
- Convert pasted CSS font-size px→pt (model uses points)
- Trim trailing punctuation from auto-detected URLs
- Preserve block-level metadata (type, style, heading, list) for
  head/tail blocks in multi-block paste
- Make editor prop reactive via useState so DocsLinkPopover and
  DocsFindBar receive the editor on first render

Co-Authored-By: Claude Opus 4.6 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
packages/docs/src/view/editor.ts (1)

313-320: ⚠️ Potential issue | 🟡 Minor

Search matches still lose wrap-boundary affinity.

These pseudo-ranges are still built from bare offsets before computeSelectionRects(). A match that starts or ends exactly at a visual wrap can still highlight and scroll on the wrong line; carry explicit start/end affinity through this path.

Based on learnings In packages/docs/src/view/*, when computing selection boundaries and wrap-aware cursor/navigation behavior, include and consistently thread lineAffinity through the relevant selection/text-editor helpers.

Also applies to: 809-815

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/editor.ts` around lines 313 - 320, The search match
pseudo-ranges are being created from bare offsets so wrap-boundary affinity is
lost; update the code that builds matches for searchHighlightRects (the
searchMatches.map block that calls computeSelectionRects) to include explicit
start/end (or anchor/focus) lineAffinity values on the selection objects and
ensure computeSelectionRects (and the analogous creation at lines 809-815)
accepts and threads those affinity properties through the selection/text-editor
helpers so wrap-aware highlighting and scrolling use the provided affinities.
packages/docs/src/view/clipboard.ts (1)

88-94: ⚠️ Potential issue | 🟠 Major

Sanitize pasted hrefs before they enter the model.

This assigns raw anchor targets straight to InlineStyle.href. Unsafe schemes are blocked later when opening, but the value is still persisted and synced here. Filter imported hrefs through the shared allowlist up front.

Possible fix
 import type { Block, Inline, InlineStyle } from '../model/types.js';
 import { inlineStylesEqual } from '../model/types.js';
+import { isSafeUrl } from './url-detect.js';
@@
     if (tag === 'a') {
       const href = el.getAttribute('href');
-      if (href) {
+      if (href && isSafeUrl(href)) {
         style.href = href;
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/clipboard.ts` around lines 88 - 94, The code assigns
raw anchor hrefs directly to InlineStyle.href (inside the tag === 'a' branch)
which persists unsafe schemes; update this to pass el.getAttribute('href')
through the shared allowlist/sanitizer module (the project's canonical href
filter, e.g., isHrefAllowed/sanitizeHref) and only set style.href when the
sanitizer returns an allowed/sanitized value (otherwise leave undefined/null);
locate the anchor handling in clipboard.ts (the tag === 'a' block using el and
style.href) and replace the direct assignment with a call to the shared
allowlist API so pasted hrefs are filtered before entering the model.
packages/docs/src/view/url-detect.ts (1)

49-57: ⚠️ Potential issue | 🟠 Major

Auto-link still misses parenthesized and quoted URLs.

detectUrlBeforeCursor() still requires the whole token to start with http, so (https://example.com) and quoted URLs never link. The blanket closing-delimiter trim also chops valid URLs that legitimately end with a balanced ). Search for the scheme inside the token and only strip unmatched trailing punctuation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/url-detect.ts` around lines 49 - 57,
detectUrlBeforeCursor() currently assumes the token starts with the scheme and
trims any trailing delimiter chars blindly; instead locate the first occurrence
of /https?:\/\// inside the token (use the token variable) and set url =
token.slice(schemeIndex) so parenthesized/quoted prefixes are handled. When
trimming trailing punctuation from url, only strip closing delimiters that are
unmatched: iterate removing trailing chars from url while keeping a balance
count for parentheses, square brackets and braces and ignoring balanced trailing
quotes/quote-pairs, so you don't remove legitimately balanced closing ')' or '"'
at the end of a URL. After extraction ensure url.length > 'https://'.length and
return { start, end: start + url.length, url } from detectUrlBeforeCursor().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/docs/src/view/clipboard.ts`:
- Around line 47-53: parseHtmlToInlines currently flattens all HTML into inline
runs and newlines so block-level semantics (h1–h6, p, li, blockquote, pre,
ul/ol) are lost; change parseHtmlToInlines to preserve block information by
either augmenting the Inline type with block metadata (e.g., blockType,
headingLevel, listType/listLevel, blockAttrs) or by returning a small wrapper
(e.g., Block[] or Inline|BlockBoundary tokens) that indicates block boundaries
and types; update the DOM walk in parseHtmlToInlines to detect block elements
(h1..h6, p, li, ul, ol, blockquote, pre, etc.) and emit the corresponding block
markers/metadata so packages/docs/src/view/text-editor.ts can reconstruct
original block structure and attributes.

In `@packages/docs/src/view/text-editor.ts`:
- Around line 1432-1451: The insertPlainText flow is treating every block as
editable text (using this.doc.insertText, this.doc.splitBlock and cursor moves)
which breaks for non-text blocks like horizontal-rule; add a reusable guard
helper (e.g., ensureEditableTextBlock or isTextBlock) and call it at the start
of insertPlainText and at the single-block rich-paste / no-selection link-insert
path so that when the current block is not a text block you first replace or
split into a new paragraph block (createParagraphBefore/After or call
this.doc.createBlock('paragraph') and move the cursor there) before calling
this.doc.insertText or this.doc.splitBlock, ensuring all insert/split logic only
runs on real text blocks; apply the same guard to the code around lines
1465-1475 and to the no-selection link insert code path so they reuse the
helper.
- Around line 633-655: The final unconditional blocks.push is creating a phantom
empty paragraph after the newline-splitting loop; change it so the trailing
block is only appended when needed by making the final push conditional (e.g.,
only push when current.length > 0 or when the original input explicitly requires
an empty paragraph) instead of always calling blocks.push(...). Update the code
around the inlines/parts loop that currently calls blocks.push({ id:
generateBlockId(), type: 'paragraph', inlines: current.length > 0 ? current : [{
text: '', style: {} }], style: { ...DEFAULT_BLOCK_STYLE } }) so it only executes
when current contains content or when an explicit empty-paragraph should be
preserved.

---

Duplicate comments:
In `@packages/docs/src/view/clipboard.ts`:
- Around line 88-94: The code assigns raw anchor hrefs directly to
InlineStyle.href (inside the tag === 'a' branch) which persists unsafe schemes;
update this to pass el.getAttribute('href') through the shared
allowlist/sanitizer module (the project's canonical href filter, e.g.,
isHrefAllowed/sanitizeHref) and only set style.href when the sanitizer returns
an allowed/sanitized value (otherwise leave undefined/null); locate the anchor
handling in clipboard.ts (the tag === 'a' block using el and style.href) and
replace the direct assignment with a call to the shared allowlist API so pasted
hrefs are filtered before entering the model.

In `@packages/docs/src/view/editor.ts`:
- Around line 313-320: The search match pseudo-ranges are being created from
bare offsets so wrap-boundary affinity is lost; update the code that builds
matches for searchHighlightRects (the searchMatches.map block that calls
computeSelectionRects) to include explicit start/end (or anchor/focus)
lineAffinity values on the selection objects and ensure computeSelectionRects
(and the analogous creation at lines 809-815) accepts and threads those affinity
properties through the selection/text-editor helpers so wrap-aware highlighting
and scrolling use the provided affinities.

In `@packages/docs/src/view/url-detect.ts`:
- Around line 49-57: detectUrlBeforeCursor() currently assumes the token starts
with the scheme and trims any trailing delimiter chars blindly; instead locate
the first occurrence of /https?:\/\// inside the token (use the token variable)
and set url = token.slice(schemeIndex) so parenthesized/quoted prefixes are
handled. When trimming trailing punctuation from url, only strip closing
delimiters that are unmatched: iterate removing trailing chars from url while
keeping a balance count for parentheses, square brackets and braces and ignoring
balanced trailing quotes/quote-pairs, so you don't remove legitimately balanced
closing ')' or '"' at the end of a URL. After extraction ensure url.length >
'https://'.length and return { start, end: start + url.length, url } from
detectUrlBeforeCursor().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f48eacbb-5bc4-48a8-8c6a-13e3dd8663d6

📥 Commits

Reviewing files that changed from the base of the PR and between 09fa533 and d20132f.

📒 Files selected for processing (10)
  • packages/docs/src/index.ts
  • packages/docs/src/model/document.ts
  • packages/docs/src/view/clipboard.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/find-replace.ts
  • packages/docs/src/view/text-editor.ts
  • packages/docs/src/view/url-detect.ts
  • packages/frontend/src/app/docs/docs-find-bar.tsx
  • packages/frontend/src/app/docs/docs-link-popover.tsx
  • packages/frontend/src/app/docs/docs-view.tsx
✅ Files skipped from review due to trivial changes (1)
  • packages/docs/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/frontend/src/app/docs/docs-view.tsx
  • packages/docs/src/view/find-replace.ts

Comment thread packages/docs/src/view/clipboard.ts Outdated
Comment thread packages/docs/src/view/text-editor.ts Outdated
Comment thread packages/docs/src/view/text-editor.ts
- Rewrite parseHtmlToInlines → parseHtmlToBlocks returning Block[]
  with proper block semantics (headings, list items, blockquote)
- Fix phantom empty paragraph from trailing block separators
- Add ensureEditableBlock guard for horizontal-rule blocks
- Remove unused DEFAULT_BLOCK_STYLE import
- Create docs-phase2-lessons.md

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@hackerwins hackerwins changed the title feat(docs): Phase 2 inline extensions & clipboard Add phase 2 inline extensions & clipboard Mar 27, 2026
@hackerwins
hackerwins merged commit f46c014 into main Mar 27, 2026
3 checks passed
@hackerwins
hackerwins deleted the feat/docs-phase2 branch March 27, 2026 12:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/docs/src/view/text-editor.ts (1)

1556-1570: Duplicate implementation of inline style comparison.

inlineStylesMatch duplicates the logic already available as inlineStylesEqual imported from ../model/types.js (used in clipboard.ts). Consider reusing the shared utility for consistency and maintainability.

Suggested fix

Add to imports at line 2:

-import { generateBlockId, getBlockText, getBlockTextLength } from '../model/types.js';
+import { generateBlockId, getBlockText, getBlockTextLength, inlineStylesEqual } from '../model/types.js';

Then replace inlineStylesMatch calls with inlineStylesEqual:

-      if (last && this.inlineStylesMatch(last.style, inline.style)) {
+      if (last && inlineStylesEqual(last.style, inline.style)) {

And remove the inlineStylesMatch method (lines 1556-1570).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/text-editor.ts` around lines 1556 - 1570, Replace the
duplicated inline style comparison by importing the shared utility
inlineStylesEqual from ../model/types.js, update all usages of the local method
inlineStylesMatch in this file to call inlineStylesEqual instead, and then
delete the inlineStylesMatch method implementation (the private
inlineStylesMatch function) to avoid duplication and keep consistency with
clipboard.ts which already uses inlineStylesEqual.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/docs/src/view/clipboard.ts`:
- Around line 29-38: The TAG_STYLE_MAP constant is missing mappings for <sup>
and <sub>, so pasted HTML loses superscript/subscript formatting; add entries
"sup": { sup: true } and "sub": { sub: true } (or use the existing InlineStyle
property names used elsewhere for superscript/subscript) to TAG_STYLE_MAP so the
HTML parser maps those tags to the corresponding InlineStyle; update the
Record<string, Partial<InlineStyle>> in packages/docs/src/view/clipboard.ts
accordingly, using the exact InlineStyle property names your codebase expects
for superscript and subscript.

---

Nitpick comments:
In `@packages/docs/src/view/text-editor.ts`:
- Around line 1556-1570: Replace the duplicated inline style comparison by
importing the shared utility inlineStylesEqual from ../model/types.js, update
all usages of the local method inlineStylesMatch in this file to call
inlineStylesEqual instead, and then delete the inlineStylesMatch method
implementation (the private inlineStylesMatch function) to avoid duplication and
keep consistency with clipboard.ts which already uses inlineStylesEqual.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4d227df9-1407-4287-9843-5f9fbedae080

📥 Commits

Reviewing files that changed from the base of the PR and between d20132f and 6db2c81.

📒 Files selected for processing (3)
  • docs/tasks/active/20260326-docs-phase2-lessons.md
  • packages/docs/src/view/clipboard.ts
  • packages/docs/src/view/text-editor.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/tasks/active/20260326-docs-phase2-lessons.md

Comment on lines +29 to +38
const TAG_STYLE_MAP: Record<string, Partial<InlineStyle>> = {
b: { bold: true },
strong: { bold: true },
i: { italic: true },
em: { italic: true },
u: { underline: true },
s: { strikethrough: true },
del: { strikethrough: true },
strike: { strikethrough: true },
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add <sup> and <sub> tag mappings for superscript/subscript.

The PR adds superscript/subscript support, but the HTML parser doesn't map <sup> and <sub> tags. Pasting HTML containing these elements will lose the formatting.

Proposed fix
 const TAG_STYLE_MAP: Record<string, Partial<InlineStyle>> = {
   b: { bold: true },
   strong: { bold: true },
   i: { italic: true },
   em: { italic: true },
   u: { underline: true },
   s: { strikethrough: true },
   del: { strikethrough: true },
   strike: { strikethrough: true },
+  sup: { superscript: true },
+  sub: { subscript: true },
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const TAG_STYLE_MAP: Record<string, Partial<InlineStyle>> = {
b: { bold: true },
strong: { bold: true },
i: { italic: true },
em: { italic: true },
u: { underline: true },
s: { strikethrough: true },
del: { strikethrough: true },
strike: { strikethrough: true },
};
const TAG_STYLE_MAP: Record<string, Partial<InlineStyle>> = {
b: { bold: true },
strong: { bold: true },
i: { italic: true },
em: { italic: true },
u: { underline: true },
s: { strikethrough: true },
del: { strikethrough: true },
strike: { strikethrough: true },
sup: { superscript: true },
sub: { subscript: true },
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs/src/view/clipboard.ts` around lines 29 - 38, The TAG_STYLE_MAP
constant is missing mappings for <sup> and <sub>, so pasted HTML loses
superscript/subscript formatting; add entries "sup": { sup: true } and "sub": {
sub: true } (or use the existing InlineStyle property names used elsewhere for
superscript/subscript) to TAG_STYLE_MAP so the HTML parser maps those tags to
the corresponding InlineStyle; update the Record<string, Partial<InlineStyle>>
in packages/docs/src/view/clipboard.ts accordingly, using the exact InlineStyle
property names your codebase expects for superscript and subscript.

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