Skip to content

feat(composer): hand-rolled contenteditable editor engine - #33

Merged
rpvilo merged 5 commits into
feature/composer-editor-seamfrom
feature/composer-editor
Jul 10, 2026
Merged

feat(composer): hand-rolled contenteditable editor engine#33
rpvilo merged 5 commits into
feature/composer-editor-seamfrom
feature/composer-editor

Conversation

@rpvilo

@rpvilo rpvilo commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

PR 2 of 3 — the replacement editor engine, stacked on #32.

Composer.Textarea is now a hand-rolled contenteditable over a flat {text|chip}[] segment model; the TipTap implementation stays mountable as Composer.TextareaLegacy for side-by-side comparison until PR 3 deletes it.

Design invariants: the browser owns text editing (native typing/IME; model syncs from the DOM via read → diff → commit); programmatic ops are the only imperative DOM writes; engine state lives outside React (version-counter subscription, chip visuals via portals into engine-owned spans).

  • segments.ts — pure kernel: splice/slice, mapPosition (PM bias semantics), diffFlatText, planBeforeInput allow-list policy, snapshot converters (engine-portable under the ComposerSnapshot brand)
  • trigger-tracker.ts — sticky-token port of the PM plugin (spaces in query, dismissedAt suppression)
  • editor-dom.ts — DOM read-back (padding-<br> pact, NBSP normalization, badge-span transparency), chip-span reuse keeps React portals stable
  • use-composer-editor.tsx — engine factory + hook; full RegisteredEditor; IME composition guard incl. keyCode-229 Enter protection; blocked native undo (parity: the TipTap setup shipped no History extension)
  • New props: maxLength (chip = 1), required, name (hidden-input FormData mirror), forwarded natives (spellCheck, enterKeyHint, inputMode, dir, aria-*); baked-in role="textbox"/aria-multiline

Accepted divergences: onValueChange emits "\n" per soft break (legacy: "\n\n"); copy/cut emit chip-markdown; in-editor text drag-drop denied in v1; IME maxLength enforced at composition commit.

Verified: 156/156 tests (68 new), tsc --noEmit clean, biome clean, package builds. Store/command-list/popover/container untouched.

Manual checklist (before merge)

Compare against legacy by pointing the styled ComposerTextarea in components/ai/composer.tsx at ComposerPrimitive.TextareaLegacy.

  • Basics: typing, Enter submit, Shift+Enter soft break, placeholder toggle, autoFocus, disabled
  • Commands: @ opens; badge + "Type to filter" hint; spaces in query keep it open; Escape dismissal suppression; ArrowLeft/Right caret trap; click-outside closes; Tab/Enter select → chip + trailing space
  • Chips: backspace/forward-delete remove whole chip; arrows skip as one unit; range-select delete; copy/cut/paste round-trip (chip markdown); paste files → attachments
  • IME (macOS Safari + Chrome): Hiragana + Pinyin composition; Enter confirming a conversion must not submit; badge behavior around composition; long-press accents; dictation
  • Ask-user: arrows navigate options + editor blurs; type-to-answer refocuses; single-select clears on typing
  • Form: name mirrors serialized text into FormData; required blocks empty submit
  • Multi-line: type past max-h cap → scrolling; trailing empty line reachable

@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
intentface-chat Ready Ready Preview, Comment Jul 10, 2026 1:06pm

Request Review

@rpvilo

rpvilo commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Added the native-textarea callback surface: onFocus/onBlur/onKeyDown/onKeyUp/onPaste/onCopy/onCut now route to the editable element (not the wrapper) and run before the engine — preventDefault in onKeyDown/clipboard handlers overrides the engine's handling, exactly like a native textarea. Checklist addition: verify a consumer onKeyDown that preventDefaults Enter blocks the submit.

@rpvilo

rpvilo commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Two more additions from testing:

1. Soft break dismisses an active trigger token (the screenshot bug): the tracker's sticky range was absorbing inserted newlines, dragging the badge across lines and keeping the popup open. Now: Shift+Enter while the popup is open dismisses the token Escape-style (with re-entry suppression) and then inserts the break; the tracker additionally enforces that a token can never span a line break (covers pasted newlines too).

2. submitOn prop"enter" (default, current behavior) or "shift-enter" (Enter breaks, Shift+Enter sends). The mapping lives in the pure interpretEditorKey kernel; the send chord is suppressed while the command popup is open in both modes.

Checklist additions:

  • Repro the screenshot case: @query → Shift+Enter → popup closes, break inserts, badge doesn't fragment across lines; caret back into the token does not reopen it
  • submitOn="shift-enter": Enter breaks, Shift+Enter sends; with popup open, Enter selects and Shift+Enter soft-breaks

@rpvilo
rpvilo merged commit 8609ad0 into feature/composer-editor-seam Jul 10, 2026
2 checks passed
@rpvilo

rpvilo commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Fix for the reported disappearing-editor bug (98fe977): line-break parity invariant. Browsers add/remove <br>s freely during native edits — emptying the editor can leave no <br> (the unpadded editable collapses to zero height, hiding the caret and the placeholder overlay, which sizes to it → looks like the whole Textarea vanished), and typing before the padding <br> strands it mid-content where later reads count it as a phantom newline (the rapid-backspace line breaks). Now every native sync compares the DOM's <br> count to the canonical rendering and repaints from the model on divergence. Plain typing never diverges, so the fast path is untouched; IME guard applies.

Checklist addition:

  • Repro: mentions back-and-forth + rapid backspace to empty — the editor line, caret, and placeholder must survive; no phantom line breaks accumulate

@rpvilo

rpvilo commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Fix for the two-line-editor screenshot (hasContent port bug): legacy computed text.trim().length > 0 || !instance.isEmpty — the second half made any structure count as content. My port replaced it with a chips check, silently dropping the whitespace-only case: a doc holding just "\n" (Shift+Enter on empty, or newline residue from delete flows) reported hasContent: false, so the placeholder painted over a legitimately two-line editor. Now hasContent = documentLength(doc) > 0 — exact legacy parity and native-textarea parity (placeholder hides on any character, including a newline). Repro for the checklist: Shift+Enter in the empty composer → placeholder must disappear, caret on line two, backspace restores the placeholder.

@rpvilo

rpvilo commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up: this PR's squash landed on feature/composer-editor-seam (its base at merge time), not main — the engine never actually shipped. Superseded by the re-land PR from feature/composer-editor, which also carries the post-merge bug fixes.

rpvilo added a commit that referenced this pull request Jul 10, 2026
…33) (#37)

* feat(composer): hand-rolled contenteditable editor engine; TipTap becomes Composer.TextareaLegacy

* feat(composer): editable-routed event callbacks with consumer-first override semantics

* fix(composer): hide placeholder during IME composition; add native placeholder prop

* refactor(composer): single-writer hasContent; placeholder gates on engine isComposing

* feat(composer): submitOn prop for Enter/Shift+Enter mapping; soft break dismisses active trigger token

* fix(composer): repaint on br-count drift — collapse on emptied editor + phantom newlines

* fix(composer): whitespace-only docs count as content — placeholder no longer overlays line breaks

* feat(composer): ghost-text completion of the highlighted command item (#35)

* feat(composer): ghost-text completion of the highlighted command item

* style(composer): borderless chip + badge surface in the input

* feat(composer): per-prefix suggestion flag gating ghost-text completion

* feat(composer): per-prefix command placeholder, exclusive with suggestion

* refactor(composer): badge hints as a real command-hint slot element, replacing ::after attribute CSS
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