import { useCallback, useEffect, useState } from "react"; import { applyInlineStyle } from "./inlineTextStyleRange"; import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead"; import { parseCssColor, toHexColor } from "./colorValue"; import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit"; /** * The controls for styling the characters selected inside an open text edit. * * It lives in Studio's document rather than the composition's, positioned over * the selection: putting it in the preview would mean injecting Studio's chrome * into the user's composition, where it would be captured by a render and * inherit the composition's own styling. * * `position: fixed` and viewport coordinates, so it does not have to know which * of the canvas' several nested coordinate systems it was mounted into. */ const READ_PROPERTIES = ["color", "font-weight", "font-style", "text-decoration-line"]; /** Enough above the text to clear it, without leaving the element behind. */ const GAP_PX = 10; /** h-6 controls + p-1 + the border. Used only to keep an above-toolbar onscreen. */ const TOOLBAR_HEIGHT_PX = 34; const DEFAULT_COLOR = "#ffffff"; interface ToolbarPlacement { left: number; top: number; placeBelow: boolean; styles: Record; colours: string[]; pickerColour: string; } export function InlineTextToolbar({ session, iframe, }: { session: InlineTextEditSession | null; iframe: HTMLIFrameElement | null; }) { const [placement, setPlacement] = useState(null); const refresh = useCallback(() => { setPlacement(session && iframe ? placeOverSelection(session.element, iframe) : null); }, [session, iframe]); // The selection lives in the preview's document, so the event does too. useEffect(() => { const doc = session?.element.ownerDocument; if (!doc) { setPlacement(null); return; } doc.addEventListener("selectionchange", refresh); return () => doc.removeEventListener("selectionchange", refresh); }, [session, refresh]); const apply = useCallback( (delta: Record) => { const doc = session?.element.ownerDocument; const selection = doc?.defaultView?.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); applyInlineStyle(range, delta); refresh(); }, [session, refresh], ); if (!placement) return null; const styles = placement.styles; return (
event.stopPropagation()} > apply({ "font-weight": on ? "700" : null })} /> apply({ "font-style": on ? "italic" : null })} /> apply({ "text-decoration-line": on ? "underline" : null })} />
); } /** * 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(); } function ToolbarToggle({ label, glyph, on, onToggle, bold, italic, underline, }: { label: string; glyph: string; on: boolean; onToggle: (on: boolean) => void; bold?: boolean; italic?: boolean; underline?: boolean; }) { return ( ); } /** Where the selection is on screen, or null when there is nothing selected. */ function placeOverSelection( element: HTMLElement, iframe: HTMLIFrameElement, ): ToolbarPlacement | null { const doc = element.ownerDocument; const view = doc.defaultView; const selection = view?.getSelection(); if (!view || !selection || selection.rangeCount === 0 || selection.isCollapsed) return null; const range = selection.getRangeAt(0); if (!element.contains(range.commonAncestorContainer)) return null; const rect = range.getBoundingClientRect(); // The composition is drawn scaled into the iframe's box, so a point inside it // is that scale away from a point on Studio's screen. This is the inverse of // the mapping the canvas uses to turn a press into a caret position. const box = iframe.getBoundingClientRect(); const scale = view.innerWidth ? box.width / view.innerWidth : 1; 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, colours, pickerColour: toPickerColour(styles.color ?? colours[0], doc), }; } function isBold(weight: string | undefined): boolean { if (!weight) return false; if (weight === "bold" || weight === "bolder") return true; return Number.parseInt(weight, 10) >= 600; } /** 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; 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; } }