feat(studio): show every colour of a mixed selection in the swatch (#3144)

* feat(studio): show every colour of a mixed selection in the text swatch

Selecting text painted in more than one colour showed a white swatch. The
toolbar reads a property only when the whole selection agrees on it, which is
right for bold and italic (a toggle is on or off) but wrong for a swatch: with
nothing to report it fell back to the default, so a red-and-green selection
claimed to be white.

The swatch now reads the colours as they run through the selection and draws
one band per run, sized by how many characters carry it. Hard stops, not a
fade — it reports the colours that are there, and a blend would draw colours
that are not. A single-colour selection is a plain swatch, as before, and
picking a colour still applies it to everything selected.

* feat(studio): blend the mixed-colour text swatch instead of banding it

Bands read as two separate swatches sitting next to each other. Each colour
now sits at the middle of its share and the browser fills between them, so the
control looks like one swatch holding a mixed selection.

* fix(studio): keep whitespace out of the text colour swatch

Colouring a whole element and then recolouring one word inside it leaves the
spaces around that word carrying the first colour. The swatch counted them, so
a red word inside green text drew a sliver of green, then red, then green —
the element's colour appearing at an edge where no glyph is painted in it.

Whitespace paints nothing, so it no longer contributes a colour. The swatch
shows the colours the glyphs are actually drawn in, in the order they appear.

* fix(studio): stop the colour swatch repeating its gradient under the border

The swatch grew a green edge on its red side and a red edge on its green side.
`background` maps a gradient to the padding box and then repeats it to fill the
border box, so the 1px ring showed the strip either side of the tile: the
gradient's end colour along the leading edge, its start colour along the
trailing one, both read as a mirrored copy of the swatch.

Painting from the border box instead gives the ring the colour the glyphs next
to it are actually drawn in.

* fix(studio): drop the highlight when a text edit closes

Picking a word with a double press and then clicking away left the word
painted grey. The element was no longer being edited, but the text still read
as selected.

Ending the edit removed contenteditable and blurred the element, and neither
of those drops the browser's own selection. It now clears the selection as
part of the teardown, and only when the selection lives inside the element
being closed — one somewhere else in the preview belongs to whatever put it
there.

* feat(studio): match the mixed-colour swatch to the one in the design tool

The swatch drew a proportional blend along the horizontal: each colour took
the share of the sweep that its characters took of the selection. At 16px that
reads as one muddy smear, and a colour used by a single character is almost
invisible — the opposite of what the control is for, which is answering "which
colours are in here".

It now sweeps diagonally through each distinct colour, evenly spaced, the way
the mixed-colour swatch works in the design tool this sits alongside. A colour
appears once however much text carries it, and the dot itself matches that
reference too: 16px, a 2px ring, and a small lift on hover.

The character counts had no other consumer, so the reader hands back the
distinct colours in document order rather than counting.

* fix(studio): harden mixed-colour text swatches

* refactor(studio): split inline text style readers
This commit is contained in:
Miguel Ángel
2026-08-11 04:26:31 -04:00
committed by GitHub
parent 4cc46f5f9f
commit 896bc336a2
7 changed files with 321 additions and 44 deletions
@@ -3,7 +3,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { InlineTextToolbar } from "./InlineTextToolbar";
import { InlineTextToolbar, swatchBackground } from "./InlineTextToolbar";
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -219,7 +219,6 @@ describe("InlineTextToolbar", () => {
expect(toolbar.style.left).toBe(`${100 + (20 + 50) * scale}px`);
expect(toolbar.style.top).toBe(`${50 + 40 * scale - 10}px`);
});
it("moves below a selection that leaves no room above the viewport", () => {
const { element, session, iframe } = scene("hello world");
iframe.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400 }) as DOMRect;
@@ -240,4 +239,58 @@ describe("InlineTextToolbar", () => {
expect(toolbar.style.top).toBe(`${10 * scale + 10}px`);
expect(toolbar.style.transform).toBe("translate(-50%, 0)");
});
it("shows the selection's colours in the swatch when they differ", () => {
const { element, session, iframe } = scene(
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
);
const { host } = render(session, iframe);
selectAll(element);
const swatch = toolbarIn(host)!.querySelector<HTMLElement>("span[aria-hidden]")!;
expect(swatch.style.backgroundImage).toBe("linear-gradient(135deg, red 0.00%, lime 100.00%)");
// Without this the gradient repeats under the border, painting the end
// colour along the leading edge and the start colour along the trailing one.
expect(swatch.style.backgroundOrigin).toBe("border-box");
});
it("shows a plain swatch when the whole selection is one colour", () => {
const { element, session, iframe } = scene('<span style="color: red">Hello world</span>');
const { host } = render(session, iframe);
selectAll(element);
const swatch = toolbarIn(host)!.querySelector<HTMLElement>("span[aria-hidden]")!;
expect(swatch.style.backgroundColor).toBe("red");
});
it("opens the colour input on a shorthand hex selection", () => {
const { element, session, iframe } = scene('<span style="color: #f00">Hello world</span>');
const { host } = render(session, iframe);
selectAll(element);
expect(host.querySelector<HTMLInputElement>('input[type="color"]')?.value).toBe("#ff0000");
});
});
describe("swatchBackground", () => {
it("sweeps through each distinct colour, evenly spaced", () => {
expect(swatchBackground(["red", "lime"], undefined)).toBe(
"linear-gradient(135deg, red 0.00%, lime 100.00%)",
);
expect(swatchBackground(["red", "lime", "cyan"], undefined)).toBe(
"linear-gradient(135deg, red 0.00%, lime 50.00%, cyan 100.00%)",
);
});
it("stays a plain swatch when the selection is one colour", () => {
expect(swatchBackground(["red"], "red")).toBe("red");
});
it("falls back to the agreed colour when the characters carry none", () => {
expect(swatchBackground([], "rgb(1, 2, 3)")).toBe("rgb(1, 2, 3)");
expect(swatchBackground([], undefined)).toBe("#ffffff");
});
});
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { applyInlineStyle, readInlineStyle } from "./inlineTextStyleRange";
import { applyInlineStyle } from "./inlineTextStyleRange";
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
import { parseCssColor, toHexColor } from "./colorValue";
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
/**
@@ -27,6 +29,8 @@ interface ToolbarPlacement {
top: number;
placeBelow: boolean;
styles: Record<string, string>;
colours: string[];
pickerColour: string;
}
export function InlineTextToolbar({
@@ -94,13 +98,21 @@ export function InlineTextToolbar({
onClick={(event) => event.stopPropagation()}
>
<label
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-white/10"
className="group relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-white/10"
title="Text colour"
>
<span
aria-hidden="true"
className="h-3.5 w-3.5 rounded-full border border-white/25"
style={{ background: styles.color || DEFAULT_COLOR }}
className="h-4 w-4 rounded-full border-2 border-white/25 transition-transform duration-150 group-hover:scale-110 group-active:scale-95"
// `background` maps a gradient to the PADDING box and then repeats it
// to fill the border box, so the 1px border shows the strip either
// side of the tile: the end colour on the left, the start colour on
// the right. A red-to-green swatch grew a green edge and a red one.
// Set after the shorthand, which resets it.
style={{
background: swatchBackground(placement.colours, styles.color),
backgroundOrigin: "border-box",
}}
/>
{/* `inset-0` is not enough on its own: a colour input carries a
user-agent minimum width, which wins over the right edge and lets
@@ -110,7 +122,7 @@ export function InlineTextToolbar({
type="color"
aria-label="Text colour"
className="absolute inset-0 h-full w-full min-w-0 cursor-pointer opacity-0"
value={toHexColor(styles.color)}
value={placement.pickerColour}
onChange={(event) => apply({ color: event.target.value })}
/>
</label>
@@ -139,6 +151,28 @@ export function InlineTextToolbar({
);
}
/**
* The selection's colours as one swatch: a diagonal sweep through each distinct
* colour, evenly spaced. A selection with one colour is a plain swatch.
*
* Distinct rather than weighted, and evenly spaced rather than proportional,
* matching the mixed-colour swatch in the design tool this sits alongside. The
* swatch answers "which colours are in here", and at 16px a colour used by one
* character has to be as visible as one used by thirty or it may as well not be
* drawn.
*/
export function swatchBackground(
distinctColours: readonly string[],
agreed: string | undefined,
): string {
if (distinctColours.length === 0) return agreed || DEFAULT_COLOR;
if (distinctColours.length === 1) return distinctColours[0]!;
const stops = distinctColours.map(
(colour, index) => `${colour} ${((index / (distinctColours.length - 1)) * 100).toFixed(2)}%`,
);
return `linear-gradient(135deg, ${stops.join(", ")})`;
}
function swallow(event: { preventDefault: () => void; stopPropagation: () => void }): void {
event.preventDefault();
event.stopPropagation();
@@ -204,11 +238,15 @@ function placeOverSelection(
const above = box.top + rect.top * scale - GAP_PX;
const placeBelow = above < TOOLBAR_HEIGHT_PX;
const styles = readInlineStyle(range, READ_PROPERTIES);
const colours = readInlineStyleSpread(range, "color");
return {
left: box.left + (rect.left + rect.width / 2) * scale,
top: placeBelow ? box.top + (rect.top + rect.height) * scale + GAP_PX : above,
placeBelow,
styles: readInlineStyle(range, READ_PROPERTIES),
styles,
colours,
pickerColour: toPickerColour(styles.color ?? colours[0], doc),
};
}
@@ -218,18 +256,26 @@ function isBold(weight: string | undefined): boolean {
return Number.parseInt(weight, 10) >= 600;
}
/**
* A colour input only accepts `#rrggbb`, and what the page reports is whatever
* the stylesheet said. An unreadable value opens the picker on white rather
* than refusing to open.
*/
function toHexColor(value: string | undefined): string {
/** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */
function toPickerColour(value: string | undefined, doc: Document): string {
if (!value) return DEFAULT_COLOR;
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
const channels = value.match(/\d+(\.\d+)?/g);
if (!channels || channels.length < 3) return DEFAULT_COLOR;
return `#${channels
.slice(0, 3)
.map((channel) => Number(channel).toString(16).padStart(2, "0"))
.join("")}`;
const parsed = parseCssColor(value);
if (parsed) return toHexColor(parsed);
// Canvas delegates the full CSS colour grammar to the browser, including
// named colours that the small serialisation parser intentionally omits.
// DOM-only test environments can lack a canvas implementation, in which
// case the picker degrades to its explicit default while the swatch remains
// truthful because CSS still paints the original value.
try {
const context = doc.createElement("canvas").getContext("2d");
if (!context) return DEFAULT_COLOR;
context.fillStyle = DEFAULT_COLOR;
context.fillStyle = value;
const normalised =
typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null;
return normalised ? toHexColor(normalised) : DEFAULT_COLOR;
} catch {
return DEFAULT_COLOR;
}
}
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyInlineStyle, readInlineStyle } from "./inlineTextStyleRange";
import { applyInlineStyle } from "./inlineTextStyleRange";
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
afterEach(() => {
vi.restoreAllMocks();
@@ -697,3 +698,46 @@ describe("applyInlineStyle when something else is painting the glyphs", () => {
expect(host.innerHTML).not.toContain("-webkit-text-fill-color");
});
});
describe("readInlineStyleSpread", () => {
it("reports every distinct colour in the selection, in order", () => {
const host = mount(
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
);
expect(readInlineStyleSpread(rangeOver(host, 0, 10), "color")).toEqual(["red", "lime"]);
});
it("reports a colour once, however many characters carry it", () => {
const host = mount('<span style="color: red">He</span><span style="color: red">llo</span>');
expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual(["red"]);
});
it("reports only what the selection covers", () => {
const host = mount(
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
);
expect(readInlineStyleSpread(rangeOver(host, 6, 10), "color")).toEqual(["lime"]);
});
it("ignores whitespace, which shows no colour at all", () => {
// Colour the whole element, then recolour one word: the whitespace around it
// keeps the first colour. It paints no glyph, so counting it puts a band of a
// colour nothing on screen is painted in at the edge of the swatch.
const host = mount(
'<span style="color: lime"> </span>' +
'<span style="color: red">Hello</span>' +
'<span style="color: lime"> world</span>',
);
expect(readInlineStyleSpread(rangeOver(host, 0, 12), "color")).toEqual(["red", "lime"]);
});
it("is empty when the characters carry no colour of their own", () => {
const host = mount("Hello world");
expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([]);
});
});
@@ -182,31 +182,23 @@ function holdsBothEnds(host: Element, range: Range): boolean {
return host.contains(range.startContainer) && host.contains(range.endContainer);
}
/**
* What the range is styled with, for a toolbar that has to open showing the
* truth rather than a default. Reports a property only when the whole range
* agrees about it, which is what a control can honestly display.
*/
export function readInlineStyle(range: Range, properties: string[]): Record<string, string> {
export interface InlineStyleChar {
char: string;
style: Readonly<Record<string, string>>;
}
/** Every character the range covers with its style, or null when it covers none. */
export function readCoveredInlineStyleChars(range: Range): readonly InlineStyleChar[] | null {
const host = editingHost(range.startContainer);
if (!host || !holdsBothEnds(host, range)) return {};
if (!host || !holdsBothEnds(host, range)) return null;
const start = offsetOf(host, range.startContainer, range.startOffset);
const end = offsetOf(host, range.endContainer, range.endOffset);
if (start === null || end === null) return {};
if (start === null || end === null) return null;
const collapsed = start === end;
const covered = charRuns(readRuns(host))
.slice(collapsed ? Math.max(0, start - 1) : start, collapsed ? Math.max(1, start) : end)
.map((entry) => entry.style);
if (covered.length === 0) return {};
const styles: Record<string, string> = {};
for (const property of properties) {
const first = covered[0]?.[property];
if (first === undefined) continue;
if (covered.every((style) => style[property] === first)) styles[property] = first;
}
return styles;
.map((entry) => ({ char: entry.char, style: entry.style }));
return covered.length > 0 ? covered : null;
}
/**
@@ -309,11 +301,18 @@ function ownStyle(element: HTMLElement): Record<string, string> {
}
/** One entry per character, which is the easiest thing to slice and compare. */
function charRuns(runs: StyledRun[]): Array<Omit<StyledRun, "text">> {
const perChar: Array<Omit<StyledRun, "text">> = [];
function charRuns(runs: StyledRun[]): Array<Omit<StyledRun, "text"> & { char: string }> {
const perChar: Array<Omit<StyledRun, "text"> & { char: string }> = [];
for (const run of runs) {
// By UTF-16 unit, not code point: `restyle` indexes this list with selection
// offsets, which count units, so an emoji has to stay two entries long.
for (let index = 0; index < run.text.length; index += 1) {
perChar.push({ style: run.style, origin: run.origin, identity: run.identity });
perChar.push({
char: run.text[index] ?? "",
style: run.style,
origin: run.origin,
identity: run.identity,
});
}
}
return perChar;
@@ -0,0 +1,47 @@
import { readCoveredInlineStyleChars } from "./inlineTextStyleRange";
/**
* What the range is styled with, for a toolbar that has to open showing the
* truth rather than a default. Reports a property only when the whole range
* agrees about it, which is what a control can honestly display.
*/
export function readInlineStyle(range: Range, properties: string[]): Record<string, string> {
const chars = readCoveredInlineStyleChars(range);
if (!chars) return {};
const styles: Record<string, string> = {};
for (const property of properties) {
const first: string | undefined = chars[0]?.style[property];
if (first === undefined) continue;
if (chars.every(({ style }) => style[property] === first)) styles[property] = first;
}
return styles;
}
/**
* The distinct values one property takes across the range, in document order:
* `["red", "lime"]`.
*
* `readInlineStyle` above answers "what is this range" and reports nothing when
* the range disagrees with itself — right for a toggle, which can only be on or
* off. A swatch can show more than one value at once, and showing the default
* instead reads as "this text is white" when none of it is.
*/
export function readInlineStyleSpread(range: Range, property: string): string[] {
const covered = readCoveredInlineStyleChars(range);
if (!covered) return [];
const seen = new Set<string>();
const spread: string[] = [];
for (const { char, style } of covered) {
// A space paints nothing, so the colour it inherits is not a colour anyone
// can see. Counting it puts the element's own colour in the swatch for text
// that shows none of it — the stray band on a selection that happens to
// start or end next to a space.
if (!char.trim()) continue;
const value = style[property];
if (value === undefined || seen.has(value)) continue;
seen.add(value);
spread.push(value);
}
return spread;
}
@@ -54,6 +54,76 @@ describe("useInlineTextEdit", () => {
act(() => root.unmount());
});
/**
* Removing contenteditable and blurring does not drop the browser's own
* highlight, so a word picked with a double press stayed painted grey after
* the click that closed the edit — text that reads as selected in an element
* that is no longer being edited.
*/
it("drops the highlight when the edit closes", () => {
const element = heading("Hello world, style me");
const { controls, root } = mount();
act(() => {
controls().start(element);
});
// Select "world", the way a double press inside the text does.
const text = element.firstChild!;
const range = document.createRange();
range.setStart(text, 6);
range.setEnd(text, 11);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
expect(selection.toString()).toBe("world");
act(() => controls().commit());
expect(selection.toString()).toBe("");
act(() => root.unmount());
});
it("leaves a selection outside the edited element alone", () => {
const element = heading("Hello world, style me");
const outsider = heading("somewhere else entirely");
const { controls, root } = mount();
act(() => {
controls().start(element);
});
const range = document.createRange();
range.selectNodeContents(outsider);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
act(() => controls().commit());
expect(selection.toString()).toBe("somewhere else entirely");
act(() => root.unmount());
});
it("drops a selection dragged across the edited element's boundary", () => {
const element = heading("Hello world");
const outsider = heading("somewhere else");
const { controls, root } = mount();
act(() => {
controls().start(element);
});
const range = document.createRange();
range.setStart(element.firstChild!, 6);
range.setEnd(outsider.firstChild!, 4);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
act(() => controls().commit());
expect(selection.toString()).toBe("");
act(() => root.unmount());
});
it("makes the element editable, and focuses it once the press has finished", async () => {
const element = heading();
const { controls, root, onPause } = mount();
@@ -66,6 +66,20 @@ export interface InlineTextEditControls {
cancel: () => void;
}
/**
* Drop the text selection, but only when it lives inside this element.
*
* A selection somewhere else in the preview belongs to whatever put it there
* and is not this session's to clear.
*/
function clearSelectionWithin(element: HTMLElement): void {
const selection = element.ownerDocument.defaultView?.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!element.contains(range.startContainer) && !element.contains(range.endContainer)) return;
selection.removeAllRanges();
}
export function useInlineTextEdit({
onCommit,
onPause,
@@ -98,6 +112,10 @@ export function useInlineTextEdit({
// Restored rather than cleared: the composition may have authored one.
open.element.style.outline = open.outline;
open.element.style.outlineOffset = open.outlineOffset;
// Drop the highlight too. Removing contenteditable and blurring leaves a
// selection made inside the element painted on screen, so a word picked
// with a double press stayed grey after the click that closed the edit.
clearSelectionWithin(open.element);
open.element.blur();
}
return open;