The pointer drawn into an editor recording lived as paths in code, so it could not be looked at or edited by hand. The shapes are now SVG files, and the editor can show the same pointer on screen, so what is recorded is what was seen. - src/core/recording/cursors/ holds one file per shape: arrow, hand, open-hand, fist, move, crosshair, text and resize. Resize is turned for the vertical and diagonal directions. - data-hot="x y" on the <svg> is the hotspot in viewBox units. Width and height in the file are ignored, and anything before <svg>, such as <?xml ...> from a drawing program, is cut off. - The open hand had six fingers. It has a thumb and four now, the middle one longest. - CURSOR_SIZE = 0.8 scales every pointer the same on screen and in a recording, so the files keep their units. - General settings has "Custom mouse pointer", on by default. It hangs the files into Pixi's cursorStyles as CSS cursors with the keyword as fallback, so the system still draws the pointer and the canvas does not. Off gives Pixi's own values back at once. - A recording draws the pointer from the same files at its own size, so it stays sharp in 4K. It still reads the shape from the canvas when that holds a url(...) cursor. Tests: - Unit recording and view-tween: 3 new, green. They read viewBox and data-hot in an edited form, the boxes of turned shapes, the default size and old settings without the switch. Red without data-hot and without cutting before <svg>. - e2e 46i-ii: the canvas cursor is a url(...) ending in default and the image loads. Off gives inherit and stores false, on brings the file back. Red without useCursorFiles. - e2e 67e: the recording is still black at the mouse position. - The README names 2046 e2e checks. - Gate on melektaus-atx: 304 files, 4702 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
6.3 KiB
TypeScript
155 lines
6.3 KiB
TypeScript
/**
|
||
* 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 = /<svg\b[^>]*>/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(/<svg\b[^>]*>/i, (root) => {
|
||
const bare = root.replace(/\s(width|height)\s*=\s*["'][^"']*["']/gi, '');
|
||
return bare.replace(/^<svg/i, `<svg width="${w}" height="${h}"`);
|
||
});
|
||
}
|
||
|
||
const ARROW = readCursor(arrow);
|
||
const RESIZE = readCursor(resize);
|
||
const RESIZE_NS = readCursor(resize, 90);
|
||
|
||
const SHAPES: Record<string, CursorShape> = {
|
||
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 `<svg>` 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(/<svg\b/i))
|
||
.replace(/<svg\b[^>]*>/i, (root) =>
|
||
root.replace(/\s(x|y)\s*=\s*["'][^"']*["']/gi, '').replace(/^<svg/i, `<svg x="${box.x}" y="${box.y}"`),
|
||
);
|
||
const svg =
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` +
|
||
`viewBox="${units(left)} ${units(top)} ${units(w)} ${units(h)}">` +
|
||
`<g transform="rotate(${turn} ${hot.x} ${hot.y})">${inner}</g></svg>`;
|
||
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||
return `url("${url}") ${Math.round(hot.x * size - left)} ${Math.round(hot.y * size - top)}, ${keyword}`;
|
||
}
|