Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion packages/studio/src/components/editor/InlineTextToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
'<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(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('<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");
});
});

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");
});
});
37 changes: 34 additions & 3 deletions packages/studio/src/components/editor/InlineTextToolbar.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -24,6 +24,7 @@ interface ToolbarPlacement {
left: number;
top: number;
styles: Record<string, string>;
colours: Array<{ value: string; chars: number }>;
}

export function InlineTextToolbar({
Expand Down Expand Up @@ -90,7 +91,15 @@ export function InlineTextToolbar({
<span
aria-hidden="true"
className="h-3.5 w-3.5 rounded-full border border-white/25"
style={{ background: styles.color || DEFAULT_COLOR }}
// `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
Expand All @@ -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 })}
/>
</label>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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(
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
);

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('<span style="color: red">He</span><span style="color: red">llo</span>');

expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([
{ value: "red", chars: 5 },
]);
});

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([
{ 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(
'<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([
{ 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([]);
});
});
73 changes: 59 additions & 14 deletions packages/studio/src/components/editor/inlineTextStyleRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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<string, string> = {};
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<string, string> }> | 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.
*
Expand Down Expand Up @@ -274,11 +312,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;
Expand Down
Loading