Draw the editor's mouse pointer from SVG files, on screen too
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>
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2045 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2046 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -1,59 +1,154 @@
|
|||||||
/**
|
/**
|
||||||
* The mouse pointer of an editor recording: the system draws it over the canvas, so the recording
|
* The mouse pointer of an editor recording: the system draws it over the canvas, so the recording
|
||||||
* draws its own. One shape per CSS cursor the editor uses, as an SVG path in CSS pixels, filled dark
|
* draws its own. The editor shows the same files as its CSS cursor unless General settings turn
|
||||||
* and outlined light like a system cursor. `hot` is the point that sits on the pointer.
|
* 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 {
|
export interface CursorShape {
|
||||||
path: string;
|
/** 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 };
|
hot: { x: number; y: number };
|
||||||
/** Degrees the path is turned by around `hot`. */
|
/** Degrees the picture is turned by around `hot`. */
|
||||||
turn: number;
|
turn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARROW = 'M0 0 L0 16 L4.2 12.2 L7 18.6 L9.6 17.5 L6.9 11.3 L12 11.3 Z';
|
/** The numbers of an attribute on the root element, null when it is missing or not numbers. */
|
||||||
const DOUBLE_ARROW = 'M-11 0 L-6 -5 L-6 -2 L6 -2 L6 -5 L11 0 L6 5 L6 2 L-6 2 L-6 5 Z';
|
function rootNumbers(svg: string, name: string, count: number): number[] | null {
|
||||||
const MOVE =
|
const root = /<svg\b[^>]*>/i.exec(svg)?.[0] ?? '';
|
||||||
'M0 -11 L4 -7 L1.5 -7 L1.5 -1.5 L7 -1.5 L7 -4 L11 0 L7 4 L7 1.5 L1.5 1.5 L1.5 7 L4 7 L0 11 ' +
|
const value = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(root)?.[1];
|
||||||
'L-4 7 L-1.5 7 L-1.5 1.5 L-7 1.5 L-7 4 L-11 0 L-7 -4 L-7 -1.5 L-1.5 -1.5 L-1.5 -7 L-4 -7 Z';
|
const numbers = value
|
||||||
const CROSS = 'M-1 -10 L1 -10 L1 -1 L10 -1 L10 1 L1 1 L1 10 L-1 10 L-1 1 L-10 1 L-10 -1 L-1 -1 Z';
|
?.trim()
|
||||||
const BEAM = 'M-4 -9 L4 -9 L4 -7.5 L1 -7.5 L1 7.5 L4 7.5 L4 9 L-4 9 L-4 7.5 L-1 7.5 L-1 -7.5 L-4 -7.5 Z';
|
.split(/[\s,]+/)
|
||||||
const HAND =
|
.map(Number);
|
||||||
'M5 0 C6 0 7 0.8 7 2 L7 7 C7.5 6.5 9 6.5 9.5 7.5 C10 7 11.5 7 12 8 C12.5 7.6 14 7.8 14 9.5 ' +
|
return numbers?.length === count && numbers.every(Number.isFinite) ? numbers : null;
|
||||||
'L14 14 C14 16.5 12.5 18 10 18 L7 18 C5.5 18 4.5 17.2 3.5 16 L0.6 11.8 C-0.2 10.6 1.2 9.2 ' +
|
}
|
||||||
'2.4 10.1 L3 10.6 L3 2 C3 0.8 4 0 5 0 Z';
|
|
||||||
const OPEN_HAND =
|
|
||||||
'M3 7 C3 5.5 5 5.5 5 7 L5 3 C5 1.5 7 1.5 7 3 L7 2 C7 0.5 9 0.5 9 2 L9 3 C9 1.5 11 1.5 11 3 ' +
|
|
||||||
'L11 5 C11 3.5 13 3.5 13 5 L13 12 C13 15.5 11 18 8 18 L7 18 C5 18 4 17 3 15.5 L0.5 11.5 ' +
|
|
||||||
'C-0.3 10.3 1.2 9 2.3 9.9 L3 10.5 Z';
|
|
||||||
const FIST =
|
|
||||||
'M3 8 C3 6.5 5 6.5 5 7.5 C5 6 7 6 7 7.2 C7 5.8 9 5.8 9 7.2 C9 6 11 6 11 7.5 C11 6.6 13 6.6 ' +
|
|
||||||
'13 8 L13 12 C13 15.5 11 18 8 18 L7 18 C5 18 4 17 3 15.5 L2 13 C1.5 11 3 10 3 10 Z';
|
|
||||||
|
|
||||||
const centred = (path: string, turn = 0): CursorShape => ({ path, hot: { x: 0, y: 0 }, turn });
|
/** 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 SHAPES: Record<string, CursorShape> = {
|
const [x, y, w, h] = rootNumbers(svg, 'viewBox', 4) ?? [0, 0, 24, 24];
|
||||||
default: { path: ARROW, hot: { x: 0, y: 0 }, turn: 0 },
|
const [hx, hy] = rootNumbers(svg, 'data-hot', 2) ?? [0, 0];
|
||||||
pointer: { path: HAND, hot: { x: 5, y: 0 }, turn: 0 },
|
return { svg, box: { x: x!, y: y!, w: w!, h: h! }, hot: { x: hx!, y: hy! }, turn };
|
||||||
grab: { path: OPEN_HAND, hot: { x: 7, y: 9 }, turn: 0 },
|
}
|
||||||
grabbing: { path: FIST, hot: { x: 7, y: 11 }, turn: 0 },
|
|
||||||
move: centred(MOVE),
|
|
||||||
crosshair: centred(CROSS),
|
|
||||||
text: centred(BEAM),
|
|
||||||
'ew-resize': centred(DOUBLE_ARROW),
|
|
||||||
'col-resize': centred(DOUBLE_ARROW),
|
|
||||||
'ns-resize': centred(DOUBLE_ARROW, 90),
|
|
||||||
'row-resize': centred(DOUBLE_ARROW, 90),
|
|
||||||
'nwse-resize': centred(DOUBLE_ARROW, 45),
|
|
||||||
'nesw-resize': centred(DOUBLE_ARROW, -45),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape for a CSS `cursor` value as the canvas has it, null for `none`. A value with images
|
* The file with its root sized `scale` times its `viewBox`, so the browser draws it sharp at that
|
||||||
* (`url(…) 4 4, pointer`) is read by its keyword at the end; anything unknown is the arrow.
|
* 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 {
|
export function cursorShape(css: string): CursorShape | null {
|
||||||
const keyword = css.split(',').at(-1)!.trim().toLowerCase();
|
const keyword = cursorKeyword(css);
|
||||||
if (keyword === 'none') return null;
|
if (keyword === 'none') return null;
|
||||||
return SHAPES[keyword] ?? SHAPES.default!;
|
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}`;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 16 23" width="16" height="23" data-hot="0 0">
|
||||||
|
<path d="M0 0 L0 16 L4.2 12.2 L7 18.6 L9.6 17.5 L6.9 11.3 L12 11.3 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -12 24 24" width="24" height="24" data-hot="0 0">
|
||||||
|
<path d="M-1 -10 L1 -10 L1 -1 L10 -1 L10 1 L1 1 L1 10 L-1 10 L-1 1 L-10 1 L-10 -1 L-1 -1 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 295 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 4 15 16" width="15" height="16" data-hot="7 11">
|
||||||
|
<path d="M3 8 C3 6.5 5 6.5 5 7.5 C5 6 7 6 7 7.2 C7 5.8 9 5.8 9 7.2 C9 6 11 6 11 7.5 C11 6.6 13 6.6 13 8 L13 12 C13 15.5 11 18 8 18 L7 18 C5 18 4 17 3 15.5 L2 13 C1.5 11 3 10 3 10 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 18 22" width="18" height="22" data-hot="5 0">
|
||||||
|
<path d="M5 0 C6 0 7 0.8 7 2 L7 7 C7.5 6.5 9 6.5 9.5 7.5 C10 7 11.5 7 12 8 C12.5 7.6 14 7.8 14 9.5 L14 14 C14 16.5 12.5 18 10 18 L7 18 C5.5 18 4.5 17.2 3.5 16 L0.6 11.8 C-0.2 10.6 1.2 9.2 2.4 10.1 L3 10.6 L3 2 C3 0.8 4 0 5 0 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26" data-hot="0 0">
|
||||||
|
<path d="M0 -11 L4 -7 L1.5 -7 L1.5 -1.5 L7 -1.5 L7 -4 L11 0 L7 4 L7 1.5 L1.5 1.5 L1.5 7 L4 7 L0 11 L-4 7 L-1.5 7 L-1.5 1.5 L-7 1.5 L-7 4 L-11 0 L-7 -4 L-7 -1.5 L-1.5 -1.5 L-1.5 -7 L-4 -7 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 0 16 20" width="16" height="20" data-hot="8 10">
|
||||||
|
<path d="M3 10.5 L3 4 C3 2.3 5.5 2.3 5.5 4 L5.5 3 C5.5 1.3 8 1.3 8 3 L8 4 C8 2.3 10.5 2.3 10.5 4 L10.5 6.5 C10.5 4.8 13 4.8 13 6.5 L13 12 C13 15.5 11 18 8 18 L7 18 C5 18 4 17 3 15.5 L0.5 11.5 C-0.3 10.3 1.2 9 2.3 9.9 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -7 26 14" width="26" height="14" data-hot="0 0">
|
||||||
|
<path d="M-11 0 L-6 -5 L-6 -2 L6 -2 L6 -5 L11 0 L6 5 L6 2 L-6 2 L-6 5 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 -11 12 22" width="12" height="22" data-hot="0 0">
|
||||||
|
<path d="M-4 -9 L4 -9 L4 -7.5 L1 -7.5 L1 7.5 L4 7.5 L4 9 L-4 9 L-4 7.5 L-1 7.5 L-1 -7.5 L-4 -7.5 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round" paint-order="stroke" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 302 B |
@@ -54,6 +54,7 @@ import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
|||||||
import { dialogSlot } from './FormDialog';
|
import { dialogSlot } from './FormDialog';
|
||||||
import { generalSettings, keepGeneralSettings } from './generalSettings';
|
import { generalSettings, keepGeneralSettings } from './generalSettings';
|
||||||
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
|
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
|
||||||
|
import { useCursorFiles } from './editorCursors';
|
||||||
import { ContainerEditor } from './container/ContainerEditor';
|
import { ContainerEditor } from './container/ContainerEditor';
|
||||||
import { CompositionEditor } from './composition/CompositionEditor';
|
import { CompositionEditor } from './composition/CompositionEditor';
|
||||||
import { EditorRun } from './EditorRun';
|
import { EditorRun } from './EditorRun';
|
||||||
@@ -428,6 +429,7 @@ export class Editor implements PlayHost {
|
|||||||
showBadge: (on) => this.toolbar.showRecording(on),
|
showBadge: (on) => this.toolbar.showRecording(on),
|
||||||
badgeRoom: () => this.toolbar.recordingRoom(),
|
badgeRoom: () => this.toolbar.recordingRoom(),
|
||||||
});
|
});
|
||||||
|
useCursorFiles(app.renderer.events, app.canvas);
|
||||||
this.play = new PlaySession(state, this);
|
this.play = new PlaySession(state, this);
|
||||||
this.editorRun = new EditorRun(state, {
|
this.editorRun = new EditorRun(state, {
|
||||||
// The game view is the one place where no key means anything to the editor, so that is where
|
// The game view is the one place where no key means anything to the editor, so that is where
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { UPDATE_PRIORITY } from 'pixi.js';
|
import { UPDATE_PRIORITY } from 'pixi.js';
|
||||||
import { cursorShape, type CursorShape } from '../core/recording/cursors';
|
import {
|
||||||
|
CURSOR_SIZE,
|
||||||
|
cursorShape,
|
||||||
|
cursorShapes,
|
||||||
|
cursorSvgAt,
|
||||||
|
type CursorShape,
|
||||||
|
} from '../core/recording/cursors';
|
||||||
import { editorRecordingPlan, type EditorRecordingSettings } from '../core/recording/editorRecording';
|
import { editorRecordingPlan, type EditorRecordingSettings } from '../core/recording/editorRecording';
|
||||||
import { elapsedText, fitPicture, recordingName } from '../core/recording/recording';
|
import { elapsedText, fitPicture, recordingName } from '../core/recording/recording';
|
||||||
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
|
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
|
||||||
@@ -216,7 +222,8 @@ class CanvasSource implements FrameSource {
|
|||||||
private readonly frame: OffscreenCanvas;
|
private readonly frame: OffscreenCanvas;
|
||||||
private readonly framePen: OffscreenCanvasRenderingContext2D;
|
private readonly framePen: OffscreenCanvasRenderingContext2D;
|
||||||
private readonly pointer = new PointerTrack();
|
private readonly pointer = new PointerTrack();
|
||||||
private readonly paths = new Map<CursorShape, Path2D>();
|
/** Each cursor file drawn at the size of the recording, once it has loaded. */
|
||||||
|
private readonly cursors = new Map<CursorShape, { scale: number; image: HTMLImageElement | null }>();
|
||||||
private copied = false;
|
private copied = false;
|
||||||
private ready: VideoFrame[] = [];
|
private ready: VideoFrame[] = [];
|
||||||
|
|
||||||
@@ -230,7 +237,10 @@ class CanvasSource implements FrameSource {
|
|||||||
this.picturePen = this.picture.getContext('2d', { alpha: false })!;
|
this.picturePen = this.picture.getContext('2d', { alpha: false })!;
|
||||||
this.frame = new OffscreenCanvas(this.size.w, this.size.h);
|
this.frame = new OffscreenCanvas(this.size.w, this.size.h);
|
||||||
this.framePen = this.frame.getContext('2d', { alpha: false })!;
|
this.framePen = this.frame.getContext('2d', { alpha: false })!;
|
||||||
if (showsPointer) this.pointer.attach(canvas);
|
if (showsPointer) {
|
||||||
|
this.pointer.attach(canvas);
|
||||||
|
for (const shape of cursorShapes()) this.cursorAt(shape, this.pointerScale());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open(): { w: number; h: number } {
|
open(): { w: number; h: number } {
|
||||||
@@ -281,25 +291,44 @@ class CanvasSource implements FrameSource {
|
|||||||
const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size);
|
const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size);
|
||||||
const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w;
|
const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w;
|
||||||
const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h;
|
const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h;
|
||||||
// Against the layout size: a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer.
|
const scale = this.pointerScale();
|
||||||
const scale = at.w / ui().app.screen.width;
|
const image = this.cursorAt(shape, scale);
|
||||||
let path = this.paths.get(shape);
|
if (!image) return;
|
||||||
if (!path) this.paths.set(shape, (path = new Path2D(shape.path)));
|
const { box, hot } = shape;
|
||||||
const pen = this.framePen;
|
const pen = this.framePen;
|
||||||
pen.save();
|
pen.save();
|
||||||
pen.translate(x, y);
|
pen.translate(x, y);
|
||||||
pen.scale(scale, scale);
|
|
||||||
pen.rotate((shape.turn * Math.PI) / 180);
|
pen.rotate((shape.turn * Math.PI) / 180);
|
||||||
pen.translate(-shape.hot.x, -shape.hot.y);
|
pen.drawImage(image, (box.x - hot.x) * scale, (box.y - hot.y) * scale, box.w * scale, box.h * scale);
|
||||||
pen.lineJoin = 'round';
|
|
||||||
pen.lineWidth = 2;
|
|
||||||
pen.strokeStyle = '#fff';
|
|
||||||
pen.stroke(path);
|
|
||||||
pen.fillStyle = '#000';
|
|
||||||
pen.fill(path);
|
|
||||||
pen.restore();
|
pen.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recording pixels per unit of a cursor file: the size the editor shows it at, in CSS pixels of
|
||||||
|
* the layout, so a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer.
|
||||||
|
*/
|
||||||
|
private pointerScale(): number {
|
||||||
|
const at = fitPicture({ w: this.canvas.width, h: this.canvas.height }, this.size);
|
||||||
|
return (at.w / ui().app.screen.width) * CURSOR_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cursor file drawn at `scale`, null while it loads; a new scale loads it anew. */
|
||||||
|
private cursorAt(shape: CursorShape, scale: number): HTMLImageElement | null {
|
||||||
|
const known = this.cursors.get(shape);
|
||||||
|
if (known?.scale === scale) return known.image;
|
||||||
|
const entry: { scale: number; image: HTMLImageElement | null } = { scale, image: known?.image ?? null };
|
||||||
|
this.cursors.set(shape, entry);
|
||||||
|
const image = new Image();
|
||||||
|
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(cursorSvgAt(shape, scale))}`;
|
||||||
|
image.decode().then(
|
||||||
|
() => {
|
||||||
|
if (this.cursors.get(shape) === entry) entry.image = image;
|
||||||
|
},
|
||||||
|
() => console.warn('[recording] a cursor SVG does not load', shape.svg.slice(0, 120)),
|
||||||
|
);
|
||||||
|
return entry.image;
|
||||||
|
}
|
||||||
|
|
||||||
collect(encode: (frame: VideoFrame) => void): void {
|
collect(encode: (frame: VideoFrame) => void): void {
|
||||||
for (const frame of this.ready.splice(0)) encode(frame);
|
for (const frame of this.ready.splice(0)) encode(frame);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export class GeneralSettingsDialog extends FormDialog {
|
|||||||
private readonly zoomField: NumberField;
|
private readonly zoomField: NumberField;
|
||||||
private showEmpty: boolean;
|
private showEmpty: boolean;
|
||||||
private readonly emptyBox: Checkbox;
|
private readonly emptyBox: Checkbox;
|
||||||
|
private customCursor: boolean;
|
||||||
|
private readonly cursorBox: Checkbox;
|
||||||
|
|
||||||
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
|
constructor(settings: GeneralSettings, onApply: (settings: GeneralSettings) => void) {
|
||||||
super(340, 'General settings');
|
super(340, 'General settings');
|
||||||
@@ -38,12 +40,25 @@ export class GeneralSettingsDialog extends FormDialog {
|
|||||||
this.showEmpty = value;
|
this.showEmpty = value;
|
||||||
};
|
};
|
||||||
this.content.add(this.formRow(this.emptyBox, new Label('Show empty asset categories and types')));
|
this.content.add(this.formRow(this.emptyBox, new Label('Show empty asset categories and types')));
|
||||||
|
this.customCursor = settings.customCursor;
|
||||||
|
this.cursorBox = new Checkbox(this.customCursor);
|
||||||
|
this.cursorBox.onChange = (value) => {
|
||||||
|
this.customCursor = value;
|
||||||
|
};
|
||||||
|
this.content.add(this.formRow(this.cursorBox, new Label('Custom mouse pointer')));
|
||||||
|
this.content.add(
|
||||||
|
new Label("The pointer an editor recording draws. Off shows the system's.", { style: 'muted' }),
|
||||||
|
);
|
||||||
this.addButtons('Apply', 'primary', () => onApply(this.values()));
|
this.addButtons('Apply', 'primary', () => onApply(this.values()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The settings as the fields stand (test hook). */
|
/** The settings as the fields stand (test hook). */
|
||||||
values(): GeneralSettings {
|
values(): GeneralSettings {
|
||||||
return { zoomTransitionMs: zoomTransitionMs(this.zoomMs), showEmptyAssetGroups: this.showEmpty };
|
return {
|
||||||
|
zoomTransitionMs: zoomTransitionMs(this.zoomMs),
|
||||||
|
showEmptyAssetGroups: this.showEmpty,
|
||||||
|
customCursor: this.customCursor,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The switch for the empty asset groups, so a check can click it. */
|
/** The switch for the empty asset groups, so a check can click it. */
|
||||||
@@ -51,6 +66,11 @@ export class GeneralSettingsDialog extends FormDialog {
|
|||||||
return this.emptyBox;
|
return this.emptyBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The switch for the custom mouse pointer, so a check can click it. */
|
||||||
|
get customCursorControl(): Checkbox {
|
||||||
|
return this.cursorBox;
|
||||||
|
}
|
||||||
|
|
||||||
/** The zoom field itself, so a check can type into it. */
|
/** The zoom field itself, so a check can type into it. */
|
||||||
get zoomControl(): NumberField {
|
get zoomControl(): NumberField {
|
||||||
return this.zoomField;
|
return this.zoomField;
|
||||||
|
|||||||
@@ -365,6 +365,13 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
const dialog = editor.generalSettingsDialog();
|
const dialog = editor.generalSettingsDialog();
|
||||||
return dialog ? centreOf(dialog.emptyGroupsControl) : null;
|
return dialog ? centreOf(dialog.emptyGroupsControl) : null;
|
||||||
},
|
},
|
||||||
|
/** Where to click the switch for the custom mouse pointer inside that dialog. */
|
||||||
|
generalSettingsCustomCursorAt: () => {
|
||||||
|
const dialog = editor.generalSettingsDialog();
|
||||||
|
return dialog ? centreOf(dialog.customCursorControl) : null;
|
||||||
|
},
|
||||||
|
/** The CSS cursor the editor's canvas shows right now. */
|
||||||
|
canvasCursor: () => ui().app.canvas.style.cursor,
|
||||||
/**
|
/**
|
||||||
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
|
* The zoom transition in ms as kept in this browser; with `ms` it is set first. The harness sets
|
||||||
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
|
* 0 on every load, so a check reading the zoom after the wheel finds the step and not the way.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { EventSystem } from 'pixi.js';
|
||||||
|
import { cursorCss, cursorKeyword, cursorKeywords } from '../core/recording/cursors';
|
||||||
|
import { generalSettings, onGeneralSettings } from './generalSettings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's mouse pointer from the files an editor recording draws (`core/recording/cursors/`),
|
||||||
|
* as the canvas's CSS cursor: the system still draws it, the canvas never does. General settings
|
||||||
|
* switch it off, and the pointer goes back to what Pixi sets by itself. Returns the way to stop.
|
||||||
|
*/
|
||||||
|
export function useCursorFiles(events: EventSystem, canvas: HTMLCanvasElement): () => void {
|
||||||
|
const own = { ...events.cursorStyles };
|
||||||
|
const files = Object.fromEntries(cursorKeywords().map((keyword) => [keyword, cursorCss(keyword)]));
|
||||||
|
const apply = (): void => {
|
||||||
|
const styles = events.cursorStyles;
|
||||||
|
for (const keyword of Object.keys(styles)) delete styles[keyword];
|
||||||
|
Object.assign(styles, own, generalSettings().customCursor ? files : {});
|
||||||
|
// Pixi sets a style only when the pointer's mode changes: the one showing now changes here.
|
||||||
|
const keyword = cursorKeyword(canvas.style.cursor);
|
||||||
|
const style = styles[keyword];
|
||||||
|
canvas.style.cursor = typeof style === 'string' ? style : keyword;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
return onGeneralSettings(apply);
|
||||||
|
}
|
||||||
@@ -12,11 +12,13 @@ export interface GeneralSettings {
|
|||||||
zoomTransitionMs: number;
|
zoomTransitionMs: number;
|
||||||
/** Whether the asset panel lists every category and type, faint where it holds no asset (#361). */
|
/** Whether the asset panel lists every category and type, faint where it holds no asset (#361). */
|
||||||
showEmptyAssetGroups: boolean;
|
showEmptyAssetGroups: boolean;
|
||||||
|
/** Whether the editor shows the mouse pointer from the recording's files rather than the system's. */
|
||||||
|
customCursor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ZOOM_TRANSITION_MAX_MS = 1000;
|
export const ZOOM_TRANSITION_MAX_MS = 1000;
|
||||||
|
|
||||||
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false };
|
const DEFAULTS: GeneralSettings = { zoomTransitionMs: 200, showEmptyAssetGroups: false, customCursor: true };
|
||||||
|
|
||||||
let settings: GeneralSettings | null = null;
|
let settings: GeneralSettings | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -43,6 +45,7 @@ function read(): GeneralSettings {
|
|||||||
return {
|
return {
|
||||||
zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs),
|
zoomTransitionMs: zoomTransitionMs(raw.zoomTransitionMs),
|
||||||
showEmptyAssetGroups: raw.showEmptyAssetGroups === true,
|
showEmptyAssetGroups: raw.showEmptyAssetGroups === true,
|
||||||
|
customCursor: raw.customCursor !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULTS };
|
return { ...DEFAULTS };
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { cursorShape } from '../../src/core/recording/cursors';
|
import {
|
||||||
|
CURSOR_SIZE,
|
||||||
|
cursorCss,
|
||||||
|
cursorKeyword,
|
||||||
|
cursorKeywords,
|
||||||
|
cursorShape,
|
||||||
|
cursorShapes,
|
||||||
|
cursorSvgAt,
|
||||||
|
readCursor,
|
||||||
|
} from '../../src/core/recording/cursors';
|
||||||
import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording';
|
import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording';
|
||||||
import {
|
import {
|
||||||
avcCodec,
|
avcCodec,
|
||||||
@@ -243,4 +252,65 @@ describe('recording the editor', () => {
|
|||||||
expect(cursorShape('grab')).not.toBe(cursorShape('default'));
|
expect(cursorShape('grab')).not.toBe(cursorShape('default'));
|
||||||
expect(cursorShape('none')).toBeNull();
|
expect(cursorShape('none')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads a cursor file: its viewBox is the picture, data-hot the point on the pointer', () => {
|
||||||
|
expect(cursorShape('pointer')).toMatchObject({
|
||||||
|
box: { x: -2, y: -2, w: 18, h: 22 },
|
||||||
|
hot: { x: 5, y: 0 },
|
||||||
|
});
|
||||||
|
expect(cursorShapes()).toHaveLength(11);
|
||||||
|
// As an editor might save it: other attributes first, commas, size in millimetres.
|
||||||
|
const edited = readCursor(
|
||||||
|
'<?xml version="1.0"?>\n<svg width="10mm" height="12mm" data-hot=" 3,4 " viewBox="1, 2, 30, 40" ' +
|
||||||
|
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
|
||||||
|
45,
|
||||||
|
);
|
||||||
|
expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 });
|
||||||
|
expect(readCursor('<svg><path /></svg>')).toMatchObject({
|
||||||
|
box: { x: 0, y: 0, w: 24, h: 24 },
|
||||||
|
hot: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
// Drawn at the size it is needed, whatever size the file names.
|
||||||
|
const sized = cursorSvgAt(edited, 2.5);
|
||||||
|
expect(sized).toContain('<svg width="75" height="100" data-hot=" 3,4 " viewBox="1, 2, 30, 40"');
|
||||||
|
expect(sized).not.toContain('mm"');
|
||||||
|
expect(sized).toContain('<path d="M0 0" />');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the editor each cursor file as a CSS cursor, turned where the shape is turned', () => {
|
||||||
|
const svgOf = (css: string): string =>
|
||||||
|
decodeURIComponent(/^url\("data:image\/svg\+xml,([^"]*)"\)/.exec(css)![1]!);
|
||||||
|
// The arrow at a unit per CSS pixel: its own picture, the hotspot counted from the picture's corner.
|
||||||
|
const arrow = cursorCss('default', undefined, 1);
|
||||||
|
const { box, hot } = cursorShape('default')!;
|
||||||
|
expect(arrow).toMatch(new RegExp(` ${hot.x - box.x} ${hot.y - box.y}, default$`));
|
||||||
|
expect(cursorKeyword(arrow)).toBe('default');
|
||||||
|
expect(cursorKeywords()).toEqual(expect.arrayContaining(['default', 'pointer', 'grab', 'nesw-resize']));
|
||||||
|
// The resize arrow, 26 × 14 around its middle, turned upright: 14 × 26, the hotspot in the middle.
|
||||||
|
const upright = cursorCss('ns-resize', undefined, 1);
|
||||||
|
expect(upright).toMatch(/ 7 13, ns-resize$/);
|
||||||
|
expect(svgOf(upright)).toContain('width="14" height="26" viewBox="-7 -13 14 26"');
|
||||||
|
expect(svgOf(upright)).toContain(
|
||||||
|
'<g transform="rotate(90 0 0)"><svg x="-13" y="-7" width="26" height="14"',
|
||||||
|
);
|
||||||
|
// Turned by 45°, the picture grows to hold the corners.
|
||||||
|
expect(cursorCss('nwse-resize', undefined, 1)).toMatch(/ 15 15, nwse-resize$/);
|
||||||
|
// Shown at CURSOR_SIZE: the arrow's 16 × 23 units are 12.8 × 18.4 pixels, rounded outwards from
|
||||||
|
// the corner at -1.6 to 14 × 19, and the viewBox still counts in the file's units.
|
||||||
|
expect(CURSOR_SIZE).toBe(0.8);
|
||||||
|
expect(cursorCss('default')).toMatch(/ 2 2, default$/);
|
||||||
|
expect(svgOf(cursorCss('default'))).toContain('width="14" height="19" viewBox="-2.5 -2.5 17.5 23.75"');
|
||||||
|
expect(cursorCss('ns-resize')).toMatch(/ 6 11, ns-resize$/);
|
||||||
|
// An edited file: what stands before its root is left out, the root is placed where its viewBox says.
|
||||||
|
const edited = readCursor(
|
||||||
|
'<?xml version="1.0"?>\n<!-- drawn by hand -->\n<svg x="5" viewBox="1 2 30 40" data-hot="3 4" ' +
|
||||||
|
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
|
||||||
|
);
|
||||||
|
const css = cursorCss('grab', edited, 1);
|
||||||
|
expect(css).toMatch(/ 2 2, grab$/);
|
||||||
|
const svg = svgOf(css);
|
||||||
|
expect(svg).not.toContain('<?xml');
|
||||||
|
expect(svg).not.toContain('drawn by hand');
|
||||||
|
expect(svg).toContain('<svg x="1" y="2" width="30" height="40" viewBox="1 2 30 40"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -408,6 +408,7 @@ describe('the general settings', () => {
|
|||||||
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
|
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
|
||||||
zoomTransitionMs: 350,
|
zoomTransitionMs: 350,
|
||||||
showEmptyAssetGroups: false,
|
showEmptyAssetGroups: false,
|
||||||
|
customCursor: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -418,12 +419,29 @@ describe('the general settings', () => {
|
|||||||
const stop = onGeneralSettings(() => (told += 1));
|
const stop = onGeneralSettings(() => (told += 1));
|
||||||
keepGeneralSettings({ showEmptyAssetGroups: true });
|
keepGeneralSettings({ showEmptyAssetGroups: true });
|
||||||
resetGeneralSettings();
|
resetGeneralSettings();
|
||||||
expect(generalSettings()).toEqual({ zoomTransitionMs: 200, showEmptyAssetGroups: true });
|
expect(generalSettings()).toEqual({
|
||||||
|
zoomTransitionMs: 200,
|
||||||
|
showEmptyAssetGroups: true,
|
||||||
|
customCursor: true,
|
||||||
|
});
|
||||||
stop();
|
stop();
|
||||||
keepGeneralSettings({ showEmptyAssetGroups: false });
|
keepGeneralSettings({ showEmptyAssetGroups: false });
|
||||||
expect(told).toBe(1);
|
expect(told).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the custom mouse pointer until switched off, and keeps the switch', () => {
|
||||||
|
const store = memoryStore();
|
||||||
|
vi.stubGlobal('window', { localStorage: store });
|
||||||
|
expect(generalSettings().customCursor).toBe(true);
|
||||||
|
keepGeneralSettings({ customCursor: false });
|
||||||
|
resetGeneralSettings();
|
||||||
|
expect(generalSettings().customCursor).toBe(false);
|
||||||
|
// Settings kept before the switch existed show it.
|
||||||
|
store.setItem('game-maker.general-settings', JSON.stringify({ zoomTransitionMs: 100 }));
|
||||||
|
resetGeneralSettings();
|
||||||
|
expect(generalSettings().customCursor).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('holds the transition to whole ms from 0 to 1000', () => {
|
it('holds the transition to whole ms from 0 to 1000', () => {
|
||||||
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
|
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
|
||||||
expect(zoomTransitionMs('fast')).toBe(200);
|
expect(zoomTransitionMs('fast')).toBe(200);
|
||||||
|
|||||||
@@ -435,6 +435,49 @@ export async function topbar(t) {
|
|||||||
JSON.stringify({ general, kept }),
|
JSON.stringify({ general, kept }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 46i-ii. The custom mouse pointer, on by default: the canvas shows the cursor file an editor
|
||||||
|
// recording draws as its CSS cursor (Chrome took the value, the picture loads, the keyword
|
||||||
|
// stays at the end). Switched off in the dialog the system's keyword is back, on again the file.
|
||||||
|
// `json` would stringify the promise itself: this one hands back its JSON once it is settled.
|
||||||
|
const pictureLoads = async () =>
|
||||||
|
JSON.parse(
|
||||||
|
await t.evaluate(`(async () => {
|
||||||
|
const url = /^url\\("([^"]*)"\\)/.exec(__gm.canvasCursor())?.[1];
|
||||||
|
if (!url) return 'null';
|
||||||
|
const image = new Image();
|
||||||
|
image.src = url;
|
||||||
|
try { await image.decode(); return JSON.stringify([image.naturalWidth, image.naturalHeight]); }
|
||||||
|
catch { return '"broken"'; }
|
||||||
|
})()`),
|
||||||
|
);
|
||||||
|
const switchCursor = async () => {
|
||||||
|
await clickToolbar('menu');
|
||||||
|
await clickText('General settings…');
|
||||||
|
const box = await json('__gm.generalSettingsCustomCursorAt()');
|
||||||
|
await t.click(box.x, box.y);
|
||||||
|
await clickText('Apply');
|
||||||
|
await sleep(100);
|
||||||
|
};
|
||||||
|
const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() };
|
||||||
|
await switchCursor();
|
||||||
|
const cursorOff = { css: await json('__gm.canvasCursor()') };
|
||||||
|
const storedOff = await json(
|
||||||
|
`JSON.parse(localStorage.getItem('game-maker.general-settings')).customCursor`,
|
||||||
|
);
|
||||||
|
await switchCursor();
|
||||||
|
const cursorBack = await json('__gm.canvasCursor()');
|
||||||
|
check(
|
||||||
|
'the custom mouse pointer is on by default as the CSS cursor, General settings switch it off and on',
|
||||||
|
general?.customCursor === true &&
|
||||||
|
/^url\("data:image\/svg\+xml,.*"\) \d+ \d+, default$/.test(cursorOn.css) &&
|
||||||
|
Array.isArray(cursorOn.size) &&
|
||||||
|
cursorOn.size[0] > 0 &&
|
||||||
|
!cursorOff.css.startsWith('url(') &&
|
||||||
|
storedOff === false &&
|
||||||
|
cursorBack === cursorOn.css,
|
||||||
|
JSON.stringify({ cursorOn: { ...cursorOn, css: cursorOn.css.slice(0, 40) }, cursorOff, storedOff }),
|
||||||
|
);
|
||||||
|
|
||||||
await t.showTab('scene');
|
await t.showTab('scene');
|
||||||
const rect = await json('__gm.viewportRect()');
|
const rect = await json('__gm.viewportRect()');
|
||||||
const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) };
|
const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) };
|
||||||
|
|||||||