diff --git a/packages/studio/src/components/editor/InlineTextToolbar.test.tsx b/packages/studio/src/components/editor/InlineTextToolbar.test.tsx
index 148d786f41..c7a70106e6 100644
--- a/packages/studio/src/components/editor/InlineTextToolbar.test.tsx
+++ b/packages/studio/src/components/editor/InlineTextToolbar.test.tsx
@@ -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;
@@ -191,4 +191,51 @@ describe("InlineTextToolbar", () => {
expect(toolbar.style.left).toBe(`${100 + (20 + 50) * scale}px`);
expect(toolbar.style.top).toBe(`${50 + 40 * scale - 10}px`);
});
+ it("shows the selection's colours in the swatch when they differ", () => {
+ const { element, session, iframe } = scene(
+ 'Helloworld',
+ );
+ const { host } = render(session, iframe);
+
+ selectAll(element);
+
+ const swatch = toolbarIn(host)!.querySelector("span[aria-hidden]")!;
+ expect(swatch.style.backgroundImage).toBe("linear-gradient(90deg, red 25.00%, lime 75.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('Hello world');
+ const { host } = render(session, iframe);
+
+ selectAll(element);
+
+ const swatch = toolbarIn(host)!.querySelector("span[aria-hidden]")!;
+ expect(swatch.style.backgroundColor).toBe("red");
+ });
+});
+
+describe("swatchBackground", () => {
+ it("blends every colour in the selection, weighted by how much text carries it", () => {
+ expect(
+ swatchBackground(
+ [
+ { value: "red", chars: 5 },
+ { value: "lime", chars: 15 },
+ ],
+ undefined,
+ ),
+ ).toBe("linear-gradient(90deg, red 12.50%, lime 62.50%)");
+ });
+
+ it("stays a plain swatch when the selection is one colour", () => {
+ expect(swatchBackground([{ value: "red", chars: 5 }], "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");
+ });
});
diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx
index 8ecc2b43f7..04f5e9283e 100644
--- a/packages/studio/src/components/editor/InlineTextToolbar.tsx
+++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
-import { applyInlineStyle, readInlineStyle } from "./inlineTextStyleRange";
+import { applyInlineStyle, readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRange";
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
/**
@@ -24,6 +24,7 @@ interface ToolbarPlacement {
left: number;
top: number;
styles: Record;
+ colours: Array<{ value: string; chars: number }>;
}
export function InlineTextToolbar({
@@ -90,7 +91,15 @@ export function InlineTextToolbar({
{/* `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
@@ -100,7 +109,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={toHexColor(styles.color ?? placement.colours[0]?.value)}
onChange={(event) => apply({ color: event.target.value })}
/>
@@ -129,6 +138,27 @@ export function InlineTextToolbar({
);
}
+/**
+ * The selection's colours blended left to right, each sitting at the middle of
+ * the share of characters that carry it. A selection with one colour is a plain
+ * swatch, as before.
+ */
+export function swatchBackground(
+ colours: Array<{ value: string; chars: number }>,
+ agreed: string | undefined,
+): string {
+ if (colours.length === 0) return agreed || DEFAULT_COLOR;
+ if (colours.length === 1) return colours[0]!.value;
+ const total = colours.reduce((sum, colour) => sum + colour.chars, 0);
+ let offset = 0;
+ const stops = colours.map((colour) => {
+ const middle = ((offset + colour.chars / 2) / total) * 100;
+ offset += colour.chars;
+ return `${colour.value} ${middle.toFixed(2)}%`;
+ });
+ return `linear-gradient(90deg, ${stops.join(", ")})`;
+}
+
function swallow(event: { preventDefault: () => void; stopPropagation: () => void }): void {
event.preventDefault();
event.stopPropagation();
@@ -196,6 +226,7 @@ function placeOverSelection(
left: box.left + (rect.left + rect.width / 2) * scale,
top: box.top + rect.top * scale - GAP_PX,
styles: readInlineStyle(range, READ_PROPERTIES),
+ colours: readInlineStyleSpread(range, "color"),
};
}
diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts
index b377fbccd1..568689efa8 100644
--- a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts
+++ b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
-import { applyInlineStyle, readInlineStyle } from "./inlineTextStyleRange";
+import { applyInlineStyle, readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRange";
afterEach(() => {
vi.restoreAllMocks();
@@ -555,3 +555,56 @@ describe("applyInlineStyle when something else is painting the glyphs", () => {
expect(host.innerHTML).not.toContain("-webkit-text-fill-color");
});
});
+
+describe("readInlineStyleSpread", () => {
+ it("reports every colour in the selection, in order, with its share", () => {
+ const host = mount(
+ 'Helloworld',
+ );
+
+ expect(readInlineStyleSpread(rangeOver(host, 0, 10), "color")).toEqual([
+ { value: "red", chars: 5 },
+ { value: "lime", chars: 5 },
+ ]);
+ });
+
+ it("collapses characters that share a colour into one band", () => {
+ const host = mount('Hello');
+
+ expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([
+ { value: "red", chars: 5 },
+ ]);
+ });
+
+ it("reports only what the selection covers", () => {
+ const host = mount(
+ 'Helloworld',
+ );
+
+ expect(readInlineStyleSpread(rangeOver(host, 6, 10), "color")).toEqual([
+ { value: "lime", chars: 4 },
+ ]);
+ });
+
+ 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(
+ ' ' +
+ 'Hello' +
+ ' world',
+ );
+
+ expect(readInlineStyleSpread(rangeOver(host, 0, 12), "color")).toEqual([
+ { value: "red", chars: 5 },
+ { value: "lime", chars: 5 },
+ ]);
+ });
+
+ 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([]);
+ });
+});
diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.ts b/packages/studio/src/components/editor/inlineTextStyleRange.ts
index 53ab11d75c..1105f24219 100644
--- a/packages/studio/src/components/editor/inlineTextStyleRange.ts
+++ b/packages/studio/src/components/editor/inlineTextStyleRange.ts
@@ -176,26 +176,64 @@ function isTrailingHalf(text: string, offset: number): boolean {
* agrees about it, which is what a control can honestly display.
*/
export function readInlineStyle(range: Range, properties: string[]): Record {
- const host = editingHost(range.startContainer);
- if (!host || !holdsBothEnds(host, range)) return {};
- const start = offsetOf(host, range.startContainer, range.startOffset);
- const end = offsetOf(host, range.endContainer, range.endOffset);
- if (start === null || end === null) return {};
-
- const covered = charRuns(readRuns(host))
- .slice(start, Math.max(end, start + 1))
- .map((entry) => entry.style);
- if (covered.length === 0) return {};
+ const chars = coveredChars(range);
+ if (!chars) return {};
+ const covered = chars.map((entry) => entry.style);
const styles: Record = {};
for (const property of properties) {
- const first = covered[0]?.[property];
+ const first: string | undefined = covered[0]?.[property];
if (first === undefined) continue;
if (covered.every((style) => style[property] === first)) styles[property] = first;
}
return styles;
}
+/**
+ * What one property looks like across the range, in document order, as runs of
+ * consecutive characters that share a value: `[{ value: "red", chars: 5 },
+ * { value: "lime", chars: 6 }]`.
+ *
+ * `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,
+): Array<{ value: string; chars: number }> {
+ const covered = coveredChars(range);
+ if (!covered) return [];
+ const spread: Array<{ value: string; chars: number }> = [];
+ 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 a band of the element's own colour in the swatch
+ // for text that shows none of it — the stray edge on a selection that just
+ // happens to start or end next to a space.
+ if (!char.trim()) continue;
+ const value = style[property];
+ if (value === undefined) continue;
+ const last = spread.at(-1);
+ if (last?.value === value) last.chars++;
+ else spread.push({ value, chars: 1 });
+ }
+ return spread;
+}
+
+/** Every character the range covers with its style, or null when it covers none. */
+function coveredChars(range: Range): Array<{ char: string; style: Record }> | null {
+ const host = editingHost(range.startContainer);
+ 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 null;
+ const covered = charRuns(readRuns(host))
+ .slice(start, Math.max(end, start + 1))
+ .map((entry) => ({ char: entry.char, style: entry.style }));
+ return covered.length > 0 ? covered : null;
+}
+
/**
* The element the caret is in: the one made editable, never a span inside it.
*
@@ -274,11 +312,18 @@ function ownStyle(element: HTMLElement): Record {
}
/** One entry per character, which is the easiest thing to slice and compare. */
-function charRuns(runs: StyledRun[]): Array> {
- const perChar: Array> = [];
+function charRuns(runs: StyledRun[]): Array & { char: string }> {
+ const perChar: Array & { 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;