A vector drawing was one flat list of shapes. With many shapes the list got hard to read, and several shapes could not be kept or moved together. Now the shapes stand on layers, as in pixel art, and can be put into groups that nest. - A drawing has layers, each with a name and an eye. A hidden layer is neither in the tab nor in the picture a sprite wears, and its shapes cannot be pressed. One layer is the one worked on: the pen and Add shape draw onto it, and SHAPES lists what is on it. - The inspector has a list LAYERS over the shapes. Its plus adds a layer, the cross on a row removes it with all on it, the last one stays. A double-click renames, a drag sorts. - Group (Ctrl+G) puts the selected shapes into a group, Ungroup (Ctrl+Shift+G) takes it away. A group stands over its rows in the list, the rows one step in, and a row is dragged into a group or out of it. - In the tab a press on a shape takes its outermost group, and a drag moves the group as one undo step. - The model has layers with a tree of shapes and groups instead of a flat shapes array. The rules are pure functions in core/model/vectorTree.ts. A drawing saved before opens with all its shapes on one layer. - SortableList takes rows with a depth and drops into a tree. The flat lists of tilemap and pixel art run as before. Decided here: - A group has no transformation of its own and cannot be hidden or locked. Rotating and scaling is the follow-up #291. - A group is never empty: the one that loses its last shape goes with it. - Several selected shapes are dragged together, grouped or not, and a click on a shape of the selection keeps the selection. This is what #217 asked for. - Shift+click selects like Ctrl+click. With Select, Shift pans only beside the shapes, as in the scene view. The middle button pans anywhere, and with the pen and Paint Shift pans as before. - A row of the list picks one shape of a group alone. - Shapes change their layer through a row Layer in the inspector, which is there from two layers on. Rows are not dragged between layers. - A group cannot span two layers, the button is off then. - The pen does not draw onto a hidden layer, the hint says so. - Groups are always shown unfolded. - The four order buttons work inside the own group or layer. Tests: - Gate: 204 files, 3067 tests (3008 + 59), exit 0 - e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change: 209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned and CI measures it. - 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in tests/editor/vector-layers.test.ts - Six e2e checks with a real mouse in block 47j - 47 unit counter-probes for layers and groups and three for the Shift click all fail tests, run on stein2 - Three browser counter-probes fail their checks - Two screenshots looked at (group in the list, hidden layer) - Only unit tested: the row Layer, sorting and removing layers, copying a group, the Shift pan beside the shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
18 KiB
TypeScript
483 lines
18 KiB
TypeScript
/**
|
||
* The gestures of the four point editors (#288): a double-click puts a point down, a click picks
|
||
* one, Delete takes the picked one away – and only while one is picked. The rule itself first, then
|
||
* each editor driven through the presses its view hands it.
|
||
*/
|
||
import { CanvasTextMetrics, type FederatedPointerEvent, type Graphics } from 'pixi.js';
|
||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||
import { buildAsset, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { CurveAsset, SceneAsset, TextureAsset, VectorArtAsset } from '../../src/core/model/Asset';
|
||
import { IDENTITY } from '../../src/core/model/matrix';
|
||
import { findObject } from '../../src/core/model/hierarchy';
|
||
import { createEmptyProject } from '../../src/core/model/Project';
|
||
import type { Vec2 } from '../../src/core/model/Properties';
|
||
import { COLLIDER_POINTS_KEY, colliderPoints } from '../../src/core/properties/collider';
|
||
import { CurveCanvas } from '../../src/editor/curve/CurveCanvas';
|
||
import { deleteTakes, pickedIn, pointPress } from '../../src/editor/pointGestures';
|
||
import { pointMenuEntries } from '../../src/editor/pointMenu';
|
||
import { EditorState, objectSelection, shapeSelection } from '../../src/editor/state';
|
||
import { TextureCanvas } from '../../src/editor/texture/TextureCanvas';
|
||
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
|
||
import { PolygonCornerEditor } from '../../src/editor/viewport/polygonCorners';
|
||
import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
|
||
import { resetRenderLoop } from '../../src/ui/render';
|
||
import type { DrawItem } from '../../src/viewport/items';
|
||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||
import { allShapes } from '../../src/core/model/vectorTree';
|
||
|
||
/** Presses run on one clock, far enough apart that no two join unless a test asks for a double-click. */
|
||
let clock = 0;
|
||
|
||
function pressAt(global: Vec2, options: { double?: boolean; alt?: boolean; ctrl?: boolean } = {}) {
|
||
clock += options.double ? 100 : 1000;
|
||
return {
|
||
timeStamp: clock,
|
||
button: 0,
|
||
global,
|
||
altKey: options.alt ?? false,
|
||
ctrlKey: options.ctrl ?? false,
|
||
metaKey: false,
|
||
shiftKey: false,
|
||
stopPropagation: () => undefined,
|
||
} as unknown as FederatedPointerEvent;
|
||
}
|
||
|
||
const key = (name: string) => ({ key: name }) as KeyboardEvent;
|
||
|
||
/** What a sheet canvas does with a press, a move and a release, past the focus its widget takes. */
|
||
interface Pressable<A> {
|
||
press(asset: A, e: FederatedPointerEvent, local: Vec2): void;
|
||
move(local: Vec2): void;
|
||
release(): void;
|
||
}
|
||
|
||
function gestures<A>(canvas: unknown, asset: A) {
|
||
const target = canvas as Pressable<A>;
|
||
const click = (at: Vec2, options?: Parameters<typeof pressAt>[1]) => {
|
||
target.press(asset, pressAt(at, options), at);
|
||
target.release();
|
||
};
|
||
return {
|
||
click,
|
||
doubleClick: (at: Vec2) => {
|
||
click(at);
|
||
click(at, { double: true });
|
||
},
|
||
drag: (from: Vec2, to: Vec2) => {
|
||
target.press(asset, pressAt(from), from);
|
||
target.move(to);
|
||
target.release();
|
||
},
|
||
/** A double-click whose second press is held and dragged away before it lets go. */
|
||
doubleClickAndDrag: (at: Vec2, to: Vec2) => {
|
||
click(at);
|
||
target.press(asset, pressAt(at, { double: true }), at);
|
||
target.move(to);
|
||
target.release();
|
||
},
|
||
};
|
||
}
|
||
|
||
beforeAll(() => {
|
||
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
||
PaintCache.prototype.gradient = (() => null) as never;
|
||
});
|
||
beforeEach(() => resetRenderLoop());
|
||
|
||
describe('the rule of a press in a point editor', () => {
|
||
it('picks a point on any press, and adds only on a double-click', () => {
|
||
expect(pointPress('point', 1)).toBe('pick');
|
||
expect(pointPress('point', 2)).toBe('pick');
|
||
expect(pointPress('line', 1)).toBe('clear');
|
||
expect(pointPress('line', 2)).toBe('add');
|
||
expect(pointPress('line', 3)).toBe('clear');
|
||
});
|
||
|
||
it('adds beside the line only where the editor says so', () => {
|
||
expect(pointPress('free', 2)).toBe('clear');
|
||
expect(pointPress('free', 2, true)).toBe('add');
|
||
expect(pointPress('free', 1, true)).toBe('clear');
|
||
});
|
||
|
||
it('gives Delete the picked point, and the selection while none is picked', () => {
|
||
expect(deleteTakes(true)).toBe('point');
|
||
expect(deleteTakes(false)).toBe('selection');
|
||
});
|
||
|
||
it('drops a pick that points past the list', () => {
|
||
expect(pickedIn(3, 2)).toBe(2);
|
||
expect(pickedIn(3, 3)).toBe(-1);
|
||
expect(pickedIn(3, -1)).toBe(-1);
|
||
});
|
||
|
||
it('offers Delete point, greyed out for a point that must stay, and the type where there is one', () => {
|
||
const plain = pointMenuEntries({ removable: false, remove: () => undefined });
|
||
expect(plain).toHaveLength(1);
|
||
expect(plain[0]).toMatchObject({ label: 'Delete point', disabled: true });
|
||
const kinds: string[] = [];
|
||
const typed = pointMenuEntries({
|
||
removable: true,
|
||
remove: () => undefined,
|
||
kind: 'corner',
|
||
setKind: (kind) => kinds.push(kind),
|
||
});
|
||
const labels = typed.map((entry) => ('separator' in entry ? '-' : entry.label));
|
||
expect(labels).toEqual(['Delete point', '-', 'Straight', 'Corner', 'Smooth', 'Symmetric']);
|
||
expect(typed[0]).toMatchObject({ disabled: false });
|
||
const smooth = typed[4]!;
|
||
if (!('separator' in smooth)) smooth.onSelect();
|
||
expect(kinds).toEqual(['smooth']);
|
||
});
|
||
});
|
||
|
||
describe('the curve tab', () => {
|
||
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.resize(400, 400);
|
||
canvas.redraw();
|
||
const at = (t: number, v: number) => canvas.graphToScreen(t, v);
|
||
return { state, curve, canvas, at, ...gestures(canvas, curve) };
|
||
}
|
||
|
||
it('adds a point on a double-click, on free space too, picks it and leaves it where it is', () => {
|
||
const { state, curve, canvas, at, click, doubleClickAndDrag } = rig();
|
||
click(at(0.5, 0.9));
|
||
expect(curve.points).toHaveLength(2);
|
||
expect(canvas.pickedIndex).toBe(-1);
|
||
doubleClickAndDrag(at(0.5, 0.9), at(0.7, 0.2));
|
||
expect(curve.points).toHaveLength(3);
|
||
expect(curve.points[1]!.x).toBeCloseTo(0.5);
|
||
expect(curve.points[1]!.y).toBeCloseTo(0.9);
|
||
expect(canvas.pickedIndex).toBe(1);
|
||
state.undo();
|
||
expect(curve.points).toHaveLength(2);
|
||
});
|
||
|
||
it('adds one on the line by a double-click, and none by a click', () => {
|
||
const { curve, at, click, doubleClick } = rig();
|
||
click(at(0.5, 0.5));
|
||
expect(curve.points).toHaveLength(2);
|
||
doubleClick(at(0.5, 0.5));
|
||
expect(curve.points).toHaveLength(3);
|
||
});
|
||
|
||
it('keeps a point through a double-click on it, and drags it once it is pressed again', () => {
|
||
const { curve, canvas, at, doubleClick, drag } = rig();
|
||
doubleClick(at(0.5, 0.9));
|
||
doubleClick(at(0.5, 0.9));
|
||
expect(curve.points).toHaveLength(3);
|
||
drag(at(0.5, 0.9), at(0.5, 0.4));
|
||
expect(curve.points[1]!.y).toBeCloseTo(0.4);
|
||
expect(canvas.pickedIndex).toBe(1);
|
||
});
|
||
|
||
it('takes the picked point away on Delete, never an end, and nothing while none is picked', () => {
|
||
const { state, curve, canvas, at, click, doubleClick } = rig();
|
||
doubleClick(at(0.5, 0.9));
|
||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||
expect(curve.points).toHaveLength(2);
|
||
expect(canvas.pickedIndex).toBe(-1);
|
||
state.undo();
|
||
expect(curve.points).toHaveLength(3);
|
||
click(at(0, 0));
|
||
expect(canvas.pickedIndex).toBe(0);
|
||
canvas.onKeyDown(key('Delete'));
|
||
expect(curve.points).toHaveLength(3);
|
||
click(at(0.3, 0.9));
|
||
expect(canvas.pickedIndex).toBe(-1);
|
||
expect(canvas.onKeyDown(key('Delete'))).toBe(false);
|
||
expect(curve.points).toHaveLength(3);
|
||
});
|
||
});
|
||
|
||
describe('the vector art tab', () => {
|
||
function rig() {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||
const square = [
|
||
{ x: 10, y: 10 },
|
||
{ x: 50, y: 10 },
|
||
{ x: 50, y: 50 },
|
||
{ x: 10, y: 50 },
|
||
];
|
||
asset.layers[0]!.items.push({
|
||
id: 's',
|
||
name: 'Square',
|
||
fill: 0x0000ff,
|
||
opacity: 1,
|
||
rings: [{ closed: true, points: square }],
|
||
});
|
||
state.project.assets.push(asset);
|
||
const canvas = new VectorArtCanvas(state);
|
||
canvas.show(asset.id);
|
||
canvas.resize(400, 400);
|
||
canvas.redraw();
|
||
state.select(shapeSelection(asset.id, ['s']));
|
||
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
|
||
const points = () => allShapes(asset)[0]?.rings[0]?.points ?? [];
|
||
return { state, asset, canvas, at, points, ...gestures(canvas, asset) };
|
||
}
|
||
|
||
it('adds a point on a double-click on a line, picks it and leaves it where it is', () => {
|
||
const { state, canvas, at, points, click, doubleClickAndDrag } = rig();
|
||
click(at(30, 10));
|
||
expect(points()).toHaveLength(4);
|
||
expect(canvas.pickedKind).toBe(null);
|
||
doubleClickAndDrag(at(30, 10), at(30, 0));
|
||
expect(points()).toHaveLength(5);
|
||
expect(points()[1]).toMatchObject({ x: 30, y: 10 });
|
||
expect(canvas.pickedKind).toBe('straight');
|
||
state.undo();
|
||
expect(points()).toHaveLength(4);
|
||
});
|
||
|
||
it('carries the shape when its line is dragged', () => {
|
||
const { at, points, drag } = rig();
|
||
drag(at(30, 10), at(40, 30));
|
||
expect(points()).toHaveLength(4);
|
||
expect(points()[0]).toMatchObject({ x: 20, y: 30 });
|
||
});
|
||
|
||
it('keeps a point through a double-click on it', () => {
|
||
const { at, points, doubleClick } = rig();
|
||
doubleClick(at(50, 10));
|
||
expect(points()).toHaveLength(4);
|
||
});
|
||
|
||
it('takes the picked point away on Delete, and the shape while none is picked', () => {
|
||
const { state, asset, canvas, at, points, click } = rig();
|
||
click(at(50, 10));
|
||
expect(canvas.pickedKind).toBe('straight');
|
||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||
expect(points()).toHaveLength(3);
|
||
expect(allShapes(asset)).toHaveLength(1);
|
||
state.undo();
|
||
expect(points()).toHaveLength(4);
|
||
click(at(30, 30));
|
||
expect(canvas.pickedKind).toBe(null);
|
||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||
expect(allShapes(asset)).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('a polygon collider in the scene view', () => {
|
||
const SQUARE = [
|
||
{ x: 0, y: 0 },
|
||
{ x: 100, y: 0 },
|
||
{ x: 100, y: 100 },
|
||
{ x: 0, y: 100 },
|
||
];
|
||
|
||
function rig(corners: readonly Vec2[] = SQUARE) {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
const scene = buildAsset('scene', 'Scene') as SceneAsset;
|
||
const sprite = buildAsset('sprite', 'Crate');
|
||
state.project.assets.push(scene, sprite);
|
||
state.project.activeSceneId = scene.id;
|
||
const placed = new PlaceAssetCommand(scene.id, sprite.id, { x: 0, y: 0 });
|
||
state.execute(placed);
|
||
const collider = new AddColliderCommand(scene.id, placed.objectId);
|
||
state.execute(collider);
|
||
state.execute(new SetAssetPropertyCommand(collider.assetId, 'shape', 'polygon'));
|
||
state.execute(
|
||
new SetAssetPropertyCommand(
|
||
collider.assetId,
|
||
COLLIDER_POINTS_KEY,
|
||
corners.map((p) => ({ ...p })),
|
||
),
|
||
);
|
||
const colliderAsset = state.project.assets.find((a) => a.id === collider.assetId)!;
|
||
const props = () => ({
|
||
...colliderAsset.props,
|
||
...findObject(scene, collider.objectId)?.overrides,
|
||
});
|
||
const item = () => ({ id: collider.objectId, type: 'collider', m: IDENTITY, props: props() }) as DrawItem;
|
||
const host: SceneViewHost = {
|
||
state,
|
||
holder: () => scene,
|
||
items: () => [item()],
|
||
worldAt: (global) => global,
|
||
local: (global) => global,
|
||
view: () => ({ x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } }),
|
||
scale: () => 1,
|
||
redraw: () => undefined,
|
||
};
|
||
const editor = new PolygonCornerEditor(host);
|
||
const select = () => state.select(objectSelection(scene.id, [collider.objectId]));
|
||
select();
|
||
const points = () => colliderPoints(props());
|
||
const press = (at: Vec2, double = false) => {
|
||
const taken = editor.press(pressAt(at, { double }));
|
||
editor.release();
|
||
return taken;
|
||
};
|
||
return { state, scene, editor, points, press, select, objectId: collider.objectId };
|
||
}
|
||
|
||
it('adds a corner on a double-click on an edge, picks it and leaves it where it is', () => {
|
||
const { state, editor, points, press } = rig();
|
||
expect(press({ x: 50, y: 0 })).toBe(true);
|
||
expect(points()).toHaveLength(4);
|
||
expect(editor.pickedIndex()).toBe(-1);
|
||
expect(editor.press(pressAt({ x: 50, y: 0 }, { double: true }))).toBe(true);
|
||
expect(editor.active).toBe(false);
|
||
editor.move({ x: 50, y: -40 });
|
||
editor.release();
|
||
expect(points()).toHaveLength(5);
|
||
expect(points()[1]).toEqual({ x: 50, y: 0 });
|
||
expect(editor.pickedIndex()).toBe(1);
|
||
state.undo();
|
||
expect(points()).toHaveLength(4);
|
||
});
|
||
|
||
it('picks a corner on a click, keeps it through a double-click and drags it', () => {
|
||
const { editor, points, press } = rig();
|
||
press({ x: 100, y: 0 });
|
||
press({ x: 100, y: 0 }, true);
|
||
expect(points()).toHaveLength(4);
|
||
expect(editor.pickedIndex()).toBe(1);
|
||
editor.press(pressAt({ x: 100, y: 0 }));
|
||
editor.move({ x: 140, y: 0 });
|
||
editor.release();
|
||
expect(points()[1]).toEqual({ x: 140, y: 0 });
|
||
});
|
||
|
||
it('takes the picked corner away on Delete and leaves the collider; without a pick it takes nothing', () => {
|
||
const { state, scene, editor, points, press, objectId } = rig();
|
||
expect(editor.removePicked()).toBe(false);
|
||
press({ x: 100, y: 0 });
|
||
expect(editor.removePicked()).toBe(true);
|
||
expect(points()).toHaveLength(3);
|
||
expect(findObject(scene, objectId)).toBeDefined();
|
||
expect(editor.pickedIndex()).toBe(-1);
|
||
expect(editor.removePicked()).toBe(false);
|
||
state.undo();
|
||
expect(points()).toHaveLength(4);
|
||
});
|
||
|
||
it('keeps the last three corners', () => {
|
||
const { editor, points, press } = rig(SQUARE.slice(0, 3));
|
||
press({ x: 100, y: 0 });
|
||
expect(editor.removePicked()).toBe(true);
|
||
expect(points()).toHaveLength(3);
|
||
});
|
||
|
||
it('drops the pick on a press beside the polygon and when the collider is selected anew', () => {
|
||
const { state, editor, press, select } = rig();
|
||
press({ x: 100, y: 0 });
|
||
expect(press({ x: 300, y: 300 })).toBe(false);
|
||
expect(editor.pickedIndex()).toBe(-1);
|
||
press({ x: 100, y: 0 });
|
||
state.select(null);
|
||
select();
|
||
expect(editor.pickedIndex()).toBe(-1);
|
||
});
|
||
|
||
it('draws the picked corner', () => {
|
||
const { editor, press } = rig();
|
||
press({ x: 100, y: 100 });
|
||
const calls: string[] = [];
|
||
const g = new Proxy(
|
||
{},
|
||
{
|
||
get: (_, name: string) =>
|
||
name === 'toGlobal'
|
||
? (p: Vec2) => p
|
||
: (...args: unknown[]) => (calls.push(`${name}:${JSON.stringify(args[0])}`), g),
|
||
},
|
||
) as unknown as Graphics;
|
||
editor.draw(g, { x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } });
|
||
expect(editor.info()?.picked).toBe(2);
|
||
const fills = calls.filter((call) => call.startsWith('fill:'));
|
||
expect(new Set(fills).size).toBe(2);
|
||
expect(fills.filter((fill) => fill === fills[2])).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('a collision outline in the texture tab', () => {
|
||
const TRIANGLE = [
|
||
{ x: 0, y: 32 },
|
||
{ x: 32, y: 0 },
|
||
{ x: 32, y: 32 },
|
||
];
|
||
|
||
function rig() {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
const texture = buildAsset('texture', 'Sheet') as TextureAsset;
|
||
texture.width = 64;
|
||
texture.height = 64;
|
||
texture.frames.push({
|
||
id: 'f',
|
||
name: 'Wedge',
|
||
x: 0,
|
||
y: 0,
|
||
w: 64,
|
||
h: 64,
|
||
collision: [TRIANGLE.map((p) => ({ ...p }))],
|
||
});
|
||
state.project.assets.push(texture);
|
||
const canvas = new TextureCanvas(state);
|
||
canvas.show(texture.id);
|
||
canvas.resize(400, 400);
|
||
canvas.restoreView({ view: { panX: 0, panY: 0, zoom: 4 }, needsFit: false });
|
||
canvas.setCollisionMode(true);
|
||
state.select({ kind: 'frame', assetId: texture.id, frameId: 'f' });
|
||
const at = (x: number, y: number) => ({ x: x * 4, y: y * 4 });
|
||
const outlines = () => texture.frames[0]?.collision ?? [];
|
||
return { state, texture, canvas, at, outlines, ...gestures(canvas, texture) };
|
||
}
|
||
|
||
it('adds a corner on a double-click on an edge, picks it and leaves it where it is', () => {
|
||
const { state, canvas, at, outlines, click, doubleClickAndDrag } = rig();
|
||
click(at(32, 16));
|
||
expect(outlines()[0]).toHaveLength(3);
|
||
expect(canvas.pickedCorner).toBe(null);
|
||
doubleClickAndDrag(at(32, 16), at(50, 16));
|
||
expect(outlines()[0]).toHaveLength(4);
|
||
expect(outlines()[0]![2]).toEqual({ x: 32, y: 16 });
|
||
expect(canvas.pickedCorner).toEqual({ outline: 0, index: 2 });
|
||
state.undo();
|
||
expect(outlines()[0]).toHaveLength(3);
|
||
});
|
||
|
||
it('keeps a corner through a double-click on it', () => {
|
||
const { at, outlines, doubleClick } = rig();
|
||
doubleClick(at(32, 0));
|
||
expect(outlines()[0]).toHaveLength(3);
|
||
});
|
||
|
||
it('takes the picked corner away on Delete, and the whole outline while none is picked', () => {
|
||
const { state, texture, canvas, at, outlines, click } = rig();
|
||
click(at(32, 0));
|
||
expect(canvas.pickedCorner).toEqual({ outline: 0, index: 1 });
|
||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||
expect(outlines()[0]).toHaveLength(2);
|
||
state.undo();
|
||
expect(outlines()[0]).toHaveLength(3);
|
||
click(at(32, 16));
|
||
expect(canvas.pickedCorner).toBe(null);
|
||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||
expect(outlines()).toHaveLength(0);
|
||
expect(texture.frames).toHaveLength(1);
|
||
});
|
||
|
||
it('still draws an outline corner by corner, the last one picked', () => {
|
||
const { canvas, at, outlines, click } = rig();
|
||
click(at(50, 50));
|
||
expect(outlines()[0]).toHaveLength(4);
|
||
expect(canvas.pickedCorner).toEqual({ outline: 0, index: 3 });
|
||
canvas.onKeyDown(key('Delete'));
|
||
expect(outlines()[0]).toHaveLength(3);
|
||
});
|
||
});
|