feat(studio): apply a style to a run of characters - #3142
Conversation
5ac9e7a to
bb983d2
Compare
23fa478 to
41dcfb1
Compare
bb983d2 to
50b1699
Compare
41dcfb1 to
74b7814
Compare
50b1699 to
f184959
Compare
74b7814 to
5a73168
Compare
f184959 to
bf1fc56
Compare
5a73168 to
4e34dfa
Compare
bf1fc56 to
5524e6d
Compare
ea0dd12 to
f9e7ea0
Compare
5524e6d to
3ca092e
Compare
Styling text in a composition cannot be done by wrapping a DOM range in a span. That is three lines, and then every interesting case is a special case: recolouring nests spans that shadow each other, removing a style cannot reach the ancestor that set it, and styling across an existing run's boundary has to split it. Each fix is a new branch and the branches interact. So the element is read into a flat list of styled runs, the style is applied to a span of characters in that list, and the element is rebuilt from it. Replacing, removing, splitting and merging stop being cases: the rebuild emits one span per distinct run and cannot nest or duplicate, whatever was there before. Selection offsets count UTF-16 units, so a boundary can land between the halves of an emoji; the applied range widens to whole characters. A colour an ancestor overpaints is mirrored into the fill, because a colour that does not paint reads to the user as a colour that did not save. The toolbar that drives this arrives with the editor in the next change.
f9e7ea0 to
4d5913a
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Read the code (inlineTextStyleRange.ts + .test.ts in full at head 4d5913a7) and verified findings against the sanitizer allowlist from PR #3141. The read-flat-runs / rebuild approach is the right shape over range surgery, and the test file's naming ("flex containers", "data-hf-text-key identity", "break sentinels", "overpainted-fill") shows careful thinking about the real failure modes. Two correctness gaps stop me from stamping.
Blockers
1. Grapheme widening only handles UTF-16 surrogate pairs — ZWJ sequences, regional-indicator flags, skin-tone modifiers, and combining marks still split. isTrailingHalf at inlineTextStyleRange.ts:168-171:
function isTrailingHalf(text: string, offset: number): boolean {
const code = text.charCodeAt(offset);
return code >= 0xdc00 && code <= 0xdfff;
}That widens the single-code-point emoji case (👍, tested at .test.ts:317 and :330). It does not widen:
- ZWJ family
👨👩👧(5 code points joined by U+200D ZWJ) — a boundary at the ZWJ or between joined glyphs passes the surrogate check and splits the cluster. - Regional-indicator flags
🇺🇸(two surrogate pairs) — boundary at index 2 sits AFTER the trailing surrogate,isTrailingHalfreturns false, flag halves land in different spans. - Skin-tone
👍🏽(base + modifier, both surrogate pairs) — boundary between them splits, giving base + orphaned modifier. - Combining marks
é(é) — both code units are BMP, no surrogate anywhere,isTrailingHalfreturns false and base/combining land in different runs.
None of these appear in the test file — grepped for ZWJ / regional-indicator / skin-tone-modifier / combining-mark payloads and got zero matches. The docstring at :142 says "widened so they never fall inside a character" which reads as user-perceived character (extended grapheme cluster), but the code only implements code-point-pair. That's the whole reason the widening exists; shipping it as-is means the emoji-safety claim is only true for the tested subset. Intl.Segmenter with granularity: "grapheme" is available in every browser Studio targets — please widen to grapheme clusters and add tests for ZWJ / RI-flag / skin-tone / combining-mark.
2. Overpaint mirror over-applies: one overpainted subtree stamps -webkit-text-fill-color on every coloured run in the host. applyInlineStyle:100:
if (colourIsOverpainted(host)) render(host, next.map(mirrorFillColor));colourIsOverpainted(host) at :119-131 scans host.querySelectorAll("span[style*='color']") and returns true if ANY span in the host is being overpainted by an ancestor. Then next.map(mirrorFillColor) (via mirrorFillColor at :135-139) stamps -webkit-text-fill-color: <run.color> on every run in the array that has a color. Not just the runs whose ancestor path actually overpaints.
In a document with two subtrees where only one has -webkit-text-fill-color on an ancestor, edits in the untouched subtree still stamp fill colors onto runs whose ancestors never overpainted. That's the stale-mirror problem: extra style bytes now win over any future ancestor overpaint change on the untouched subtree, breaking the "designer changes ancestor fill, all descendants follow" contract downstream. The stubbed test at .test.ts:538-545 exercises only the single-host case and can't catch this.
Two fixes worth considering: (a) evaluate overpaint per rendered span, only mirror the ones failing; (b) reassess mirror validity on subsequent edits (drop the mirror when the ancestor no longer overpaints).
Non-blockers (worth resolving in comment)
3. preservedAttributes (:1006-1013) copies every non-style, non-data-hf-id attribute. The sanitizer's FORMATTING_ATTRS allows only data-hf-text-key + data-hf-id. Anything else the origin carries (class, id, role, stray onclick from a paste) survives the rebuild in-editor but is stripped on save. Consider passing this through an allowlist that matches the sanitizer, so what the user sees mid-edit matches what persists.
4. editingHost selector .closest("[contenteditable]") (:777) matches contenteditable="false" too. If a caret ever lands inside a false subtree nested in a true editor, this rebuilds the false region. Use .isContentEditable or [contenteditable=""], [contenteditable="true"].
5. Overpaint detection span[style*='color'] (:122) substring-matches background-color/border-color/caret-color. Covered defensively by if (!span.style.color) continue, so nit — cleaner as [style*='color:'] or a full host.querySelectorAll('span') with the property check.
6. readInlineStyle collapsed-range semantics at :185-186: .slice(start, Math.max(end, start + 1)) reports the character after the caret for a collapsed range. Toolbars usually reflect the char before (last-typed state). Worth naming the choice in the docstring.
CI
The failing Test on run 31463831279 completed at 06:07:51Z — only 3 seconds after starting at 06:07:48Z, which is push-cancellation semantics (a newer commit was pushed at 06:07:34Z triggering a fresh run at 06:10:59Z currently IN_PROGRESS). The reported Test FAILURE is aggregation of the cancelled run, not a real regression. Re-check when the fresh run lands.
Verdict
REQUEST_CHANGES. The read-flat-runs approach is right, but the emoji-widening claim is only true for the single-code-point case (any composed emoji ships broken after an edit), and the overpaint mirror stamps redundant -webkit-text-fill-color bytes on every colored run in the host — inviting a downstream stale-mirror bug. Both are addressable with focused fixes; non-blockers are follow-up polish. Happy to re-review after the two blockers.
— Via
What
The engine that applies a style to selected characters inside an element. Pure logic, no UI.
Why
Wrapping a DOM range in a span is three lines, and then every interesting case is a special case: recolouring nests spans that shadow each other, removing a style cannot reach the ancestor that set it, and styling across an existing run's boundary has to split it. Each fix is a new branch and the branches interact.
How
The element is read into a flat list of styled runs, the style is applied to a span of characters in that list, and the element is rebuilt from it. Replacing, removing, splitting and merging stop being cases: the rebuild emits one span per distinct run and cannot nest or duplicate, whatever was there before.
Selection offsets count UTF-16 units, so a boundary can land inside a visible character made from multiple code points; the applied range widens to whole grapheme clusters. A colour an ancestor overpaints is mirrored into the fill only for the affected run, because a colour that does not paint reads to the user as a colour that did not save.
Test plan
The toolbar that drives this arrives in the next PR.