Skip to content

feat(studio): edit and style text in the preview - #3077

Open
miguel-heygen wants to merge 1 commit into
mainfrom
feat-studio-inline-rich-text
Open

feat(studio): edit and style text in the preview#3077
miguel-heygen wants to merge 1 commit into
mainfrom
feat-studio-inline-rich-text

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

Text in the canvas can now be edited and styled where it sits.

Double-press a text element and the caret opens at the point you pressed. Type, and it types. Select characters and a small toolbar appears over the selection with colour, bold, italic and underline, applied to exactly those characters. Enter commits, Escape cancels, clicking away keeps the work. Double click takes the word and triple click takes the lot, because nothing here claims either gesture.

Why

Changing a word previously meant leaving the canvas for the design panel, and styling part of a line was not possible at all.

Not possible for a concrete reason: Studio had no patch operation that could write markup. The vocabulary was inline-style, attribute, html-attribute and text-content, and text-content assigns textContent. The text-field model escapes markup with escapeHtmlText on the way out, and buildTextFieldChildOperations returns null when the child count changes, which throws. A <span> had no route to a composition file by any path.

How

A rich-text operation, separate from text-content. Widening text-content would have turned the design panel and every other caller into a markup sink at once. The new operation is the only one that can carry markup, which keeps the dangerous one explicit and greppable.

One sanitiser, in packages/core, called on both ends. The client sanitises so the preview shows what will be saved; the server sanitises because it writes the file and a client is not a thing to trust. Two implementations would drift and the drift would be a security bug. The allowlist is small: span b strong i em u br, style only, and five paint-only properties. An unexpected tag is unwrapped, so a stray paste loses its formatting rather than the user's words.

Styling rebuilds rather than wraps. Wrapping a DOM range is three lines and then every case is a special case: recolouring nests spans that shadow each other, removing a style cannot reach the ancestor that set it, styling across a run has to split it. Instead the element is read into a flat list of styled runs, the delta is applied to a span of characters, and the element is rebuilt. Replace, remove, split and merge stop being cases, and the markup cannot grow with repeated edits.

Layout is preserved in containers that box their children. In flex, grid or -webkit-box, every child is an item to lay out, so text that was one anonymous item would become several the moment a word was styled, breaking centring and rewrapping the line. The runs go inside a single wrapper there.

The selection chrome stands down while editing. pointer-events: none on the overlay does not disable a child that sets auto, and the selection box covers exactly the text being typed into. Left interactive it swallowed every press, so the caret could not be moved and characters could not be dragged over.

Shortcut guards moved to a shared check. They matched [contenteditable='true'] and missed the editing surface, so playback shortcuts claimed letters typed into the composition: a seeked to the in-point instead of typing an a.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

Suites on this branch: studio 3572 passing, studio-server 431, core utils 49. Around 120 tests are new.

Covered deliberately: the sanitiser against both parsers the codebase uses (jsdom and linkedom, which is what makes "one module, two runtimes" a claim rather than a hope); the server operation for hostile payloads, markup-shaped text, non-ASCII and idempotency; and the styling model for source newlines, emoji whose halves a selection boundary can fall between, a selection dragged outside the element, and flex or grid containers.

Verified by hand in Studio: styled a run, committed, read the span back out of the composition file on disk, reloaded and reopened the element for editing. Element measured 659x337 before and after styling a word inside it.

Known and deliberately left: a formatting span gets a fresh data-hf-id on each restyle, which is diff noise on a line already being edited; preserving it means threading identity through a model that merges and splits runs. Deleting every character and committing empties the element, which then opens only through the design panel, unchanged from before.

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.

Styling text needed a capability Studio did not have. The patch vocabulary
was inline-style, attribute, html-attribute and text-content, and
text-content assigns textContent; the text-field model escapes markup on
the way out and refuses a change in child structure. No route existed for
a span to reach a composition file.

Adds a rich-text operation that has one, guarded by a single sanitiser in
packages/core called on both ends of the trip: in the browser so the
preview shows what will be saved, and on the server because that is where
the file is written. Tags and style properties are a small allowlist, and
an unexpected tag loses its formatting rather than its words.

Styling is applied by reading the element into a flat list of runs and
rebuilding it, not by wrapping a DOM range. Replacing a colour, removing
one, and styling across an existing run stop being special cases, and the
markup cannot grow with repeated edits. In a flex or grid container the
runs go inside one wrapper so a coloured word cannot reflow the element.

Also fixes the keyboard: the shortcut guards matched contenteditable=true
only, so playback shortcuts ate letters typed into the composition.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Fallow audit report

Found 17 findings.

Duplication (15)
Severity Rule Location Description
minor fallow/code-duplication packages/studio/src/components/editor/DomEditSelectionChrome.test.tsx:21 Code clone group 1 (20 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/components/editor/DomEditSelectionChrome.test.tsx:24 Code clone group 2 (16 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/DomEditSelectionChrome.test.tsx:76 Code clone group 1 (20 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/components/editor/DomEditSelectionChrome.test.tsx:80 Code clone group 2 (16 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/DomEditSelectionChrome.test.tsx:150 Code clone group 1 (20 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/components/editor/InlineTextToolbar.test.tsx:147 Code clone group 3 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/InlineTextToolbar.test.tsx:167 Code clone group 3 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditTextCommits.ts:249 Code clone group 4 (18 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditTextCommits.ts:325 Code clone group 4 (18 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useInlineTextEdit.test.tsx:38 Code clone group 5 (12 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useInlineTextEdit.test.tsx:43 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useInlineTextEdit.test.tsx:129 Code clone group 7 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useInlineTextEdit.test.tsx:241 Code clone group 7 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useInlineTextEdit.test.tsx:315 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useInlineTextEdit.test.tsx:331 Code clone group 5 (12 lines, 2 instances)
Health (2)
Severity Rule Location Description
minor fallow/high-crap-score packages/studio/src/components/editor/inlineTextStyleRange.test.ts:16 'rangeOver' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/inlineTextStyleRange.ts:300 'offsetOf' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)

Generated by fallow.

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