/** * A drag writes the model on every pointer move but tells the editor once per frame (#213): the * model event rebuilds panels and pictures, and a mouse reports faster than the screen draws. */ import { type Application, CanvasTextMetrics } from 'pixi.js'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import type { Command } from '../../src/core/commands/Command'; import type { CurveAsset, GraphAsset, PaletteAsset, PixelArtAsset, VectorArtAsset, VectorGradient, } from '../../src/core/model/Asset'; import { PALETTE_KEY } from '../../src/core/model/pixelart'; import { createEmptyProject } from '../../src/core/model/Project'; import { CurveCanvas } from '../../src/editor/curve/CurveCanvas'; import { FramePreview } from '../../src/editor/framePreview'; import { GraphDrags } from '../../src/editor/graph/graphDrags'; import type { GraphCanvasHost } from '../../src/editor/graph/graphHost'; import type { InspectorHost } from '../../src/editor/inspector/host'; import { previewingHandlers } from '../../src/editor/inspector/previewHandlers'; import { buildShapeSection, currentGradientBar } from '../../src/editor/inspector/shapeSection'; import { PixelArtCanvas } from '../../src/editor/pixelart/PixelArtCanvas'; import { PixelArtSide } from '../../src/editor/pixelart/PixelArtSide'; import { PixelThumbs } from '../../src/editor/pixelart/PixelThumbs'; import { EditorState } from '../../src/editor/state'; import { VStack } from '../../src/ui'; import { initRenderLoop, resetRenderLoop } from '../../src/ui/render'; import { PaintCache } from '../../src/viewport/vectorArtPicture'; import { allShapes } from '../../src/core/model/vectorTree'; /** One frame of the render loop, the way the ticker runs it. */ function frames(): () => void { const added: Array<() => void> = []; const app = { render(): void {}, ticker: { add: (fn: () => void) => added.push(fn), remove: () => undefined, }, }; initRenderLoop(app as unknown as Application); return () => added.forEach((fn) => fn()); } function fakeState() { return { selectedAnnotationIds: [] as string[], selectedNodeIds: ['n1'], preview: vi.fn(), execute: vi.fn(() => true), }; } describe('FramePreview', () => { beforeEach(() => resetRenderLoop()); it('turns any number of requests in one frame into a single preview', () => { const tick = frames(); const state = fakeState(); const preview = new FramePreview(state, 'drag'); for (let i = 0; i < 5; i++) preview.request(); expect(state.preview).not.toHaveBeenCalled(); tick(); expect(state.preview).toHaveBeenCalledTimes(1); tick(); expect(state.preview).toHaveBeenCalledTimes(1); }); it('drops the waiting preview on cancel', () => { const tick = frames(); const state = fakeState(); const preview = new FramePreview(state, 'drag'); preview.request(); preview.cancel(); tick(); expect(state.preview).not.toHaveBeenCalled(); }); it('keeps two gestures of the same name apart', () => { const tick = frames(); const a = fakeState(); const b = fakeState(); new FramePreview(a, 'drag').request(); new FramePreview(b, 'drag').request(); tick(); expect(a.preview).toHaveBeenCalledTimes(1); expect(b.preview).toHaveBeenCalledTimes(1); }); }); describe('dragging a node in the graph', () => { beforeEach(() => resetRenderLoop()); function rig() { const state = fakeState(); const asset = { id: 'b', graph: { nodes: [{ id: 'n1', type: 'Add', x: 0, y: 0, params: {} }], edges: [] }, } as unknown as GraphAsset; const host = { state: state as unknown as EditorState, assetId: () => 'b', asset: () => asset, nodeBoxes: () => [{ id: 'n1', x: 0, y: 0, w: 100, h: 40 }], toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }), zoom: () => 1, snapping: () => false, moved: vi.fn(), } as unknown as GraphCanvasHost; return { state, host, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) }; } it('moves the node with every pointer move, and the canvas follows once a frame', () => { const tick = frames(); const { host, node, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); for (let i = 1; i <= 5; i++) { drags.move({ x: 10 + i * 10, y: 10 }); expect(node.x).toBe(i * 10); } expect(host.moved).not.toHaveBeenCalled(); tick(); expect(host.moved).toHaveBeenCalledTimes(1); }); it('does not announce a changed project while the drag runs (#292)', () => { const tick = frames(); const { state, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); drags.move({ x: 50, y: 30 }); tick(); expect(state.preview).not.toHaveBeenCalled(); }); it('leaves nothing for the frame after the release: the command has the last word', () => { const tick = frames(); const { state, host, node, drags } = rig(); drags.grabNodes({ x: 10, y: 10 }); drags.move({ x: 50, y: 30 }); drags.releaseNodes(); expect(state.execute).toHaveBeenCalledTimes(1); expect(node.x).toBe(0); tick(); expect(host.moved).not.toHaveBeenCalled(); }); }); describe('editing an inspector row (#274)', () => { beforeEach(() => resetRenderLoop()); function rig() { const state = fakeState(); const target = { x: 1 }; const commit = vi.fn((value: number) => ({ value }) as unknown as Command); const handlers = previewingHandlers( { state: state as unknown as EditorState }, { target: () => target, read: (t) => t.x, write: (t, value) => (t.x = value), commit }, ); return { state, target, commit, handlers }; } it('writes every input into the model and previews once a frame', () => { const tick = frames(); const { state, target, handlers } = rig(); for (let i = 2; i <= 6; i++) { handlers.onInput(i); expect(target.x).toBe(i); } expect(state.preview).not.toHaveBeenCalled(); tick(); expect(state.preview).toHaveBeenCalledTimes(1); }); it('commits from the value before the scrub and leaves nothing for the next frame', () => { const tick = frames(); const { state, target, commit, handlers } = rig(); handlers.onInput(5); handlers.onChange(7); expect(target.x).toBe(1); expect(commit).toHaveBeenCalledWith(7); expect(state.execute).toHaveBeenCalledTimes(1); tick(); expect(state.preview).not.toHaveBeenCalled(); }); }); describe('dragging a colour stop of a gradient (#274)', () => { beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; PaintCache.prototype.gradient = (() => null) as never; }); beforeEach(() => resetRenderLoop()); function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; asset.layers[0]!.items.push({ id: 's', name: 'Sky', fill: 0x0000ff, opacity: 1, rings: [], paint: { type: 'linear', from: { x: 0, y: 0 }, to: { x: 100, y: 0 }, stops: [ { offset: 0, color: 0x0000ff, alpha: 1 }, { offset: 1, color: 0xffffff, alpha: 1 }, ], }, }); state.project.assets.push(asset); const host: InspectorHost = { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project, liveVariables: () => null, }; buildShapeSection(host, asset.id, 's', ['s']); const bar = currentGradientBar('fill')!; // 100 px between the outermost markers, which stand 6 px in from either end. bar.resize(112, 32); const pointer = (type: string, x: number) => bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined }); const offsets = () => (allShapes(asset)[0]!.paint as VectorGradient).stops.map((stop) => stop.offset); return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets }; } it('moves the stop with every pointer move and previews once a frame', () => { const tick = frames(); const { preview, pointer, offsets } = rig(); pointer('pointerdown', 6); for (let i = 1; i <= 5; i++) { pointer('globalpointermove', 6 + i * 10); expect(offsets()[0]).toBeCloseTo(i / 10); } expect(preview).not.toHaveBeenCalled(); tick(); expect(preview).toHaveBeenCalledTimes(1); }); it('leaves nothing for the frame after the release, and the drag is one undo step', () => { const tick = frames(); const { state, preview, pointer, offsets } = rig(); pointer('pointerdown', 6); pointer('globalpointermove', 56); pointer('pointerup', 56); expect(offsets()[0]).toBeCloseTo(0.5); tick(); expect(preview).not.toHaveBeenCalled(); state.undo(); expect(offsets()[0]).toBe(0); }); }); describe('the side panel of the pixel art tab (#274)', () => { beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; // Canvas and thumbnails paint onto browser canvases, which Node has none of. PixelArtCanvas.prototype.redraw = () => undefined; PixelThumbs.prototype.paint = () => undefined; }); beforeEach(() => resetRenderLoop()); function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const palette = buildAsset('palette', 'Palette') as PaletteAsset; const art = buildAsset('pixelart', 'Art') as PixelArtAsset; art.props[PALETTE_KEY] = palette.id; state.project.assets.push(palette, art); const canvas = new PixelArtCanvas(state); const side = new PixelArtSide(state, canvas); canvas.show(art.id); canvas.setColor(1); const layer = art.layers[0]!; // The row's number field calls these two; the list keeps them to itself. const opacity = side as unknown as { previewOpacity(layerId: string, opacity: number): void; commitOpacity(layerId: string, opacity: number): void; }; return { state, palette, art, layer, side, opacity, preview: vi.spyOn(state, 'preview') }; } it('writes every colour under the picker into the palette and previews once a frame', () => { const tick = frames(); const { state, palette, side, preview } = rig(); const first = palette.colors[0]!; for (const color of [0x111111, 0x222222, 0x333333]) { side.colorField.onInput?.(color); expect(palette.colors[0]).toBe(color); } expect(preview).not.toHaveBeenCalled(); tick(); expect(preview).toHaveBeenCalledTimes(1); side.colorField.onInput?.(0x444444); side.colorField.onChange?.(0x555555); tick(); expect(preview).toHaveBeenCalledTimes(1); expect(palette.colors[0]).toBe(0x555555); state.undo(); expect(palette.colors[0]).toBe(first); }); it('says so when the picker ends on the colour it started from', () => { const tick = frames(); const { palette, side, preview } = rig(); const first = palette.colors[0]!; side.colorField.onInput?.(0x111111); tick(); side.colorField.onChange?.(first); expect(palette.colors[0]).toBe(first); expect(preview).toHaveBeenCalledTimes(2); }); it('writes every step of an opacity scrub into the layer and previews once a frame', () => { const tick = frames(); const { state, art, layer, opacity, preview } = rig(); for (const value of [0.9, 0.8, 0.7]) { opacity.previewOpacity(layer.id, value); expect(layer.opacity).toBe(value); } expect(preview).not.toHaveBeenCalled(); tick(); expect(preview).toHaveBeenCalledTimes(1); opacity.previewOpacity(layer.id, 0.6); opacity.commitOpacity(layer.id, 0.5); tick(); expect(preview).toHaveBeenCalledTimes(1); expect(art.layers[0]!.opacity).toBe(0.5); state.undo(); expect(art.layers[0]!.opacity).toBe(1); }); }); describe('the place fields of the curve tab (#274)', () => { beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; }); beforeEach(() => resetRenderLoop()); function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const curve = buildAsset('curve', 'Ease') as CurveAsset; state.project.assets.push(curve); const canvas = new CurveCanvas(state); canvas.show(curve.id); (canvas as unknown as { picked: number }).picked = 1; return { state, curve, canvas, preview: vi.spyOn(state, 'preview') }; } it('moves the picked point with every input and previews once a frame', () => { const tick = frames(); const { state, curve, canvas, preview } = rig(); const before = curve.points[1]!.y; for (const v of [0.9, 0.8, 0.7]) { canvas.movePicked({ x: 1, y: v }, false); expect(canvas.pickedPlace?.y).toBeCloseTo(v); } expect(preview).not.toHaveBeenCalled(); tick(); expect(preview).toHaveBeenCalledTimes(1); canvas.movePicked({ x: 1, y: 0.6 }, false); canvas.movePicked({ x: 1, y: 0.5 }, true); tick(); expect(preview).toHaveBeenCalledTimes(1); expect(curve.points[1]!.y).toBeCloseTo(0.5); state.undo(); expect(curve.points[1]!.y).toBe(before); }); });