/** * The mouse pointer of an editor recording: the system draws it over the canvas, so the recording * draws its own. The editor shows the same files as its CSS cursor unless General settings turn * that off. One SVG file per shape in `cursors/`, meant to be opened and edited: its `viewBox` is * the picture, and `data-hot="x y"` on the root is the point that sits on the pointer, in the same * units. A unit is shown `CURSOR_SIZE` CSS pixels large. */ import arrow from './cursors/arrow.svg?raw'; import crosshair from './cursors/crosshair.svg?raw'; import fist from './cursors/fist.svg?raw'; import hand from './cursors/hand.svg?raw'; import move from './cursors/move.svg?raw'; import openHand from './cursors/open-hand.svg?raw'; import resize from './cursors/resize.svg?raw'; import text from './cursors/text.svg?raw'; /** * How large the files are shown, in the editor and in a recording: 1 is a CSS pixel per unit of * the `viewBox`. One number for all shapes, so the files keep the units they were drawn in. */ export const CURSOR_SIZE = 0.8; export interface CursorShape { /** The SVG file as it is. */ svg: string; /** Its `viewBox`: where the picture lies, in the file's units. */ box: { x: number; y: number; w: number; h: number }; hot: { x: number; y: number }; /** Degrees the picture is turned by around `hot`. */ turn: number; } /** The numbers of an attribute on the root element, null when it is missing or not numbers. */ function rootNumbers(svg: string, name: string, count: number): number[] | null { const root = /]*>/i.exec(svg)?.[0] ?? ''; const value = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(root)?.[1]; const numbers = value ?.trim() .split(/[\s,]+/) .map(Number); return numbers?.length === count && numbers.every(Number.isFinite) ? numbers : null; } /** Reads a cursor file. Without a `viewBox` it is taken as 24 × 24, without `data-hot` as 0 0. */ export function readCursor(svg: string, turn = 0): CursorShape { const [x, y, w, h] = rootNumbers(svg, 'viewBox', 4) ?? [0, 0, 24, 24]; const [hx, hy] = rootNumbers(svg, 'data-hot', 2) ?? [0, 0]; return { svg, box: { x: x!, y: y!, w: w!, h: h! }, hot: { x: hx!, y: hy! }, turn }; } /** * The file with its root sized `scale` times its `viewBox`, so the browser draws it sharp at that * size instead of stretching a small picture. Whatever size the file names itself is replaced. */ export function cursorSvgAt(shape: CursorShape, scale: number): string { const w = shape.box.w * scale; const h = shape.box.h * scale; return shape.svg.replace(/]*>/i, (root) => { const bare = root.replace(/\s(width|height)\s*=\s*["'][^"']*["']/gi, ''); return bare.replace(/^ = { default: ARROW, pointer: readCursor(hand), grab: readCursor(openHand), grabbing: readCursor(fist), move: readCursor(move), crosshair: readCursor(crosshair), text: readCursor(text), 'ew-resize': RESIZE, 'col-resize': RESIZE, 'ns-resize': RESIZE_NS, 'row-resize': RESIZE_NS, 'nwse-resize': readCursor(resize, 45), 'nesw-resize': readCursor(resize, -45), }; /** Every shape once, to load them all before the first frame needs one. */ export function cursorShapes(): CursorShape[] { return [...new Set(Object.values(SHAPES))]; } /** The keywords that have a shape of their own. */ export function cursorKeywords(): string[] { return Object.keys(SHAPES); } /** * The keyword of a CSS `cursor` value as the canvas has it: a value with images (`url(…) 4 4, * pointer`) is read by its keyword at the end, and Pixi's `inherit` or no value is the default. */ export function cursorKeyword(css: string): string { const keyword = css.split(',').at(-1)!.trim().toLowerCase(); return keyword === '' || keyword === 'inherit' ? 'default' : keyword; } /** The shape for a CSS `cursor` value as the canvas has it, null for `none`; anything unknown is the arrow. */ export function cursorShape(css: string): CursorShape | null { const keyword = cursorKeyword(css); if (keyword === 'none') return null; return SHAPES[keyword] ?? ARROW; } /** * A CSS `cursor` value that shows the file for `keyword` and falls back to the keyword itself: * `url(…) x y, keyword`, `size` CSS pixels per unit. A turned shape is turned inside a picture * large enough to hold it, and whatever an editor put before the `` root (an XML prolog, * comments) is left out. */ export function cursorCss( keyword: string, shape: CursorShape = SHAPES[keyword] ?? ARROW, size = CURSOR_SIZE, ): string { const { box, hot, turn } = shape; const angle = (turn * Math.PI) / 180; const cos = Math.cos(angle); const sin = Math.sin(angle); // The corners in CSS pixels, so the picture is rounded to whole pixels at the size it is shown. const corners = [ [box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h], ].map(([x, y]) => ({ x: (hot.x + (x! - hot.x) * cos - (y! - hot.y) * sin) * size, y: (hot.y + (x! - hot.x) * sin + (y! - hot.y) * cos) * size, })); // Rounded to whole pixels outwards; 1e-9 keeps a corner at 7.000000001 from growing the picture. const left = Math.floor(Math.min(...corners.map((c) => c.x)) + 1e-9); const top = Math.floor(Math.min(...corners.map((c) => c.y)) + 1e-9); const w = Math.ceil(Math.max(...corners.map((c) => c.x)) - 1e-9) - left; const h = Math.ceil(Math.max(...corners.map((c) => c.y)) - 1e-9) - top; const units = (n: number): number => Math.round((n / size) * 1e6) / 1e6; const file = cursorSvgAt(shape, 1); const inner = file .slice(file.search(/]*>/i, (root) => root.replace(/\s(x|y)\s*=\s*["'][^"']*["']/gi, '').replace(/^` + `${inner}`; const url = `data:image/svg+xml,${encodeURIComponent(svg)}`; return `url("${url}") ${Math.round(hot.x * size - left)} ${Math.round(hot.y * size - top)}, ${keyword}`; }