A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.
- With Select the selection stands in a frame of eight squares and a
knob, drawn like the one of the scene view. It holds one shape,
several shapes or a group, the same selection that is dragged as
one.
- A corner square scales from the opposite corner, a square on an
edge in its own direction. Shift keeps the proportions, Alt scales
about the middle.
- The knob turns round the middle of the frame, with Shift in steps
of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
same matrix. The rule is a pure function in vectorTransform.ts,
transformShapesCommand makes one undo step of any number of
shapes.
- A press goes to a point or a handle first, then to the frame, then
to a line. shapeFrame.ts holds where the handles are and what a
pull of each means.
Decided here:
- The frame stands 15 pixels off the shapes. The points of a shape
lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
of the turned tile. The bands of a gradient stay square to its
axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.
Found on the way:
- The inspector of a shape is now taller than the window. Two checks
of module 47 no longer found their row or button on the screen and
use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
outside the tab. The wiki names it under the limits.
Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.
Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
with Shift, type width and rotation, drag two shapes without a
group
- 47 unit counter-probes and four browser counter-probes all fail
their tests. Four of the unit ones did not at first: two showed
dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
the flip buttons and the rows X, Y and Height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
596 lines
22 KiB
TypeScript
596 lines
22 KiB
TypeScript
/**
|
||
* The frame round a vector art selection (#291): where it stands and which handle a press finds,
|
||
* what pulling a handle does in the tab, and the TRANSFORM rows of the inspector. The last block
|
||
* covers what #217 left without a test of its own – shapes dragged together.
|
||
*/
|
||
import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js';
|
||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||
import { GroupVectorShapesCommand } from '../../src/core/commands/vectorArtCommands';
|
||
import type { VectorArtAsset, VectorGradient, VectorShape } from '../../src/core/model/Asset';
|
||
import { IDENTITY } from '../../src/core/model/matrix';
|
||
import { createEmptyProject } from '../../src/core/model/Project';
|
||
import type { Vec2 } from '../../src/core/model/Properties';
|
||
import { rotationAbout } from '../../src/core/model/vectorTransform';
|
||
import { allShapes } from '../../src/core/model/vectorTree';
|
||
import type { InspectorHost } from '../../src/editor/inspector/host';
|
||
import {
|
||
buildShapeSection,
|
||
refreshShapeSection,
|
||
shapeSignature,
|
||
} from '../../src/editor/inspector/shapeSection';
|
||
import { flipSelection } from '../../src/editor/inspector/shapeTransformRows';
|
||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||
import {
|
||
FRAME_PAD,
|
||
FRAME_STEM,
|
||
frameBox,
|
||
frameCursor,
|
||
frameGripAt,
|
||
frameSizing,
|
||
frameTurning,
|
||
sidePoint,
|
||
} from '../../src/editor/vectorart/shapeFrame';
|
||
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
|
||
import { Button, NumberField, VStack, type Widget } from '../../src/ui';
|
||
import { resetRenderLoop } from '../../src/ui/render';
|
||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||
|
||
beforeAll(() => {
|
||
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
||
PaintCache.prototype.gradient = (() => null) as never;
|
||
});
|
||
beforeEach(() => resetRenderLoop());
|
||
|
||
const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 };
|
||
/** A view of two screen pixels per canvas pixel, moved by 100 and 50. */
|
||
const toLocal = (x: number, y: number): Vec2 => ({ x: 100 + x * 2, y: 50 + y * 2 });
|
||
|
||
describe('where the frame stands', () => {
|
||
it('stands a fixed number of screen pixels off the extent, at any zoom', () => {
|
||
const box = frameBox(BOX, IDENTITY, toLocal);
|
||
expect(box.corners).toEqual([
|
||
{ x: 120 - FRAME_PAD, y: 90 - FRAME_PAD },
|
||
{ x: 200 + FRAME_PAD, y: 90 - FRAME_PAD },
|
||
{ x: 200 + FRAME_PAD, y: 130 + FRAME_PAD },
|
||
{ x: 120 - FRAME_PAD, y: 130 + FRAME_PAD },
|
||
]);
|
||
expect(box.edges[0]).toEqual({ x: 160, y: 90 - FRAME_PAD });
|
||
expect(box.edges[1]).toEqual({ x: 200 + FRAME_PAD, y: 110 });
|
||
expect(box.knob).toEqual({ x: 160, y: 90 - FRAME_PAD - FRAME_STEM });
|
||
});
|
||
|
||
it('turns with the map of a drag, the knob going round with it', () => {
|
||
const box = frameBox(BOX, rotationAbout({ x: 30, y: 30 }, 90), toLocal);
|
||
// The top edge is now the right one, so the knob stands out to the right of the middle.
|
||
expect(box.knob.x).toBeCloseTo(160 + 20 + FRAME_PAD + FRAME_STEM, 6);
|
||
expect(box.knob.y).toBeCloseTo(110, 6);
|
||
expect(box.corners[0]!.x).toBeCloseTo(180 + FRAME_PAD, 6);
|
||
expect(box.corners[0]!.y).toBeCloseTo(70 - FRAME_PAD, 6);
|
||
});
|
||
|
||
it('finds the knob, a corner and an edge, and nothing in the middle', () => {
|
||
const box = frameBox(BOX, IDENTITY, toLocal);
|
||
expect(frameGripAt(box, box.knob)).toEqual({ kind: 'knob' });
|
||
expect(frameGripAt(box, { x: box.corners[2]!.x + 3, y: box.corners[2]!.y + 3 })).toEqual({
|
||
kind: 'size',
|
||
side: { sx: 1, sy: 1 },
|
||
});
|
||
expect(frameGripAt(box, { x: 140, y: box.edges[2]!.y })).toEqual({
|
||
kind: 'size',
|
||
side: { sx: 0, sy: 1 },
|
||
});
|
||
expect(frameGripAt(box, { x: 160, y: 110 })).toBeNull();
|
||
// A point of the framed shape lies on the extent: out of every handle's reach.
|
||
expect(frameGripAt(box, { x: 200, y: 130 })).toBeNull();
|
||
expect(frameGripAt(box, { x: 200, y: 110 })).toBeNull();
|
||
});
|
||
|
||
it('shows the arrow along the pull of each handle', () => {
|
||
expect(frameCursor({ kind: 'knob' })).toBe('grab');
|
||
expect(frameCursor({ kind: 'size', side: { sx: 0, sy: -1 } })).toBe('ns-resize');
|
||
expect(frameCursor({ kind: 'size', side: { sx: 1, sy: 0 } })).toBe('ew-resize');
|
||
expect(frameCursor({ kind: 'size', side: { sx: -1, sy: -1 } })).toBe('nwse-resize');
|
||
expect(frameCursor({ kind: 'size', side: { sx: 1, sy: -1 } })).toBe('nesw-resize');
|
||
});
|
||
|
||
it('moves a handle as far as the pointer went, wherever on the handle the press was', () => {
|
||
const side = { sx: 1, sy: 1 } as const;
|
||
expect(sidePoint(BOX, side)).toEqual({ x: 50, y: 40 });
|
||
expect(sidePoint(BOX, { sx: -1, sy: 0 })).toEqual({ x: 10, y: 30 });
|
||
const m = frameSizing(
|
||
BOX,
|
||
side,
|
||
{ x: 57, y: 47 },
|
||
{ x: 97, y: 57 },
|
||
{ proportional: false, centred: false },
|
||
);
|
||
expect(m).toMatchObject({ a: 2, d: 1.5 });
|
||
const snapped = frameSizing(
|
||
BOX,
|
||
side,
|
||
{ x: 57, y: 47 },
|
||
{ x: 95, y: 47 },
|
||
{
|
||
proportional: false,
|
||
centred: false,
|
||
snap: (p) => ({ x: Math.round(p.x / 8) * 8, y: Math.round(p.y / 8) * 8 }),
|
||
},
|
||
);
|
||
// 50 + 38 = 88 is on the grid already; the height stays, 40 being on it too.
|
||
expect(snapped).toMatchObject({ a: 78 / 40, d: 1 });
|
||
});
|
||
|
||
it('turns to a tenth of a degree, and in steps of fifteen with Shift', () => {
|
||
expect(frameTurning(BOX, 37.26, false)).toEqual(rotationAbout({ x: 30, y: 30 }, 37.3));
|
||
expect(frameTurning(BOX, 37.26, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 30));
|
||
expect(frameTurning(BOX, 38, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 45));
|
||
});
|
||
});
|
||
|
||
/** A square of 20 pixels with its corner at `x`, `y`. */
|
||
function square(id: string, x: number, y = 0): VectorShape {
|
||
return {
|
||
id,
|
||
name: id.toUpperCase(),
|
||
fill: 0x336699,
|
||
opacity: 1,
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x, y },
|
||
{ x: x + 20, y },
|
||
{ x: x + 20, y: y + 20 },
|
||
{ x, y: y + 20 },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
const GRADIENT: VectorGradient = {
|
||
type: 'linear',
|
||
from: { x: 80, y: 50 },
|
||
to: { x: 100, y: 50 },
|
||
stops: [
|
||
{ offset: 0, color: 0xff0000, alpha: 1 },
|
||
{ offset: 1, color: 0x0000ff, alpha: 1 },
|
||
],
|
||
};
|
||
|
||
/** A drawing with the squares a, b and c side by side at y 40, and a host to build rows into. */
|
||
function rig() {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||
asset.layers[0]!.items.push(square('a', 40, 40), square('b', 80, 40), square('c', 120, 40));
|
||
state.project.assets.push(asset);
|
||
const host: InspectorHost = {
|
||
state,
|
||
body: new VStack(),
|
||
fields: new Map(),
|
||
nameField: null,
|
||
project: () => state.project,
|
||
liveVariables: () => null,
|
||
};
|
||
const build = (): void => {
|
||
host.body.removeChildren();
|
||
host.fields.clear();
|
||
const s = state.selection;
|
||
if (s?.kind === 'shape') buildShapeSection(host, asset.id, s.shapeId, s.shapeIds);
|
||
};
|
||
const widgets = <T extends Widget>(type: new (...args: never[]) => T): T[] => {
|
||
const found: T[] = [];
|
||
const walk = (widget: Widget): void => {
|
||
if (widget instanceof type) found.push(widget);
|
||
for (const child of widget.children) walk(child as Widget);
|
||
};
|
||
walk(host.body);
|
||
return found;
|
||
};
|
||
const shape = (id: string) => allShapes(asset).find((s) => s.id === id)!;
|
||
const points = (id: string) => shape(id).rings[0]!.points.map((p) => [p.x, p.y]);
|
||
return { state, asset, host, build, widgets, shape, points };
|
||
}
|
||
|
||
let clock = 0;
|
||
|
||
function pressAt(global: Vec2, held: 'shift' | 'alt' | null = null) {
|
||
clock += 1000;
|
||
return {
|
||
timeStamp: clock,
|
||
button: 0,
|
||
global,
|
||
altKey: held === 'alt',
|
||
ctrlKey: false,
|
||
metaKey: false,
|
||
shiftKey: held === 'shift',
|
||
stopPropagation: () => undefined,
|
||
} as unknown as FederatedPointerEvent;
|
||
}
|
||
|
||
interface Pressable {
|
||
shiftPresses(asset: VectorArtAsset, local: Vec2): boolean;
|
||
press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void;
|
||
move(local: Vec2, keys?: { shiftKey: boolean; altKey: boolean }): void;
|
||
release(): void;
|
||
}
|
||
|
||
function tab() {
|
||
const r = rig();
|
||
const canvas = new VectorArtCanvas(r.state);
|
||
canvas.show(r.asset.id);
|
||
canvas.resize(512, 512);
|
||
canvas.redraw();
|
||
const target = canvas as unknown as Pressable;
|
||
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
|
||
/** Screen pixels per canvas pixel. */
|
||
const zoom = at(1, 0).x - at(0, 0).x;
|
||
const select = (...ids: string[]) => r.state.select(shapeSelection(r.asset.id, ids));
|
||
const frame = () => canvas.frameInfo!;
|
||
/** Presses at `from` and lets go `dx`, `dy` canvas pixels further, with a key held all the way. */
|
||
const pull = (from: Vec2, dx: number, dy: number, held: 'shift' | 'alt' | null = null) => {
|
||
const to = { x: from.x + dx * zoom, y: from.y + dy * zoom };
|
||
target.press(r.asset, pressAt(from, held), from);
|
||
target.move(to, { shiftKey: held === 'shift', altKey: held === 'alt' });
|
||
target.release();
|
||
};
|
||
return { ...r, canvas, target, at, zoom, select, frame, pull };
|
||
}
|
||
|
||
describe('the frame in the tab', () => {
|
||
it('frames the selection off its bounds, and nothing with another tool or no selection', () => {
|
||
const t = tab();
|
||
expect(t.canvas.frameInfo).toBeNull();
|
||
t.select('a');
|
||
const corner = t.at(40, 40);
|
||
expect(t.frame().corners[0]).toEqual({ x: corner.x - FRAME_PAD, y: corner.y - FRAME_PAD });
|
||
t.select('a', 'c');
|
||
const far = t.at(140, 60);
|
||
expect(t.frame().corners[2]).toEqual({ x: far.x + FRAME_PAD, y: far.y + FRAME_PAD });
|
||
t.canvas.setTool('pen');
|
||
expect(t.canvas.frameInfo).toBeNull();
|
||
t.canvas.setTool('paint');
|
||
expect(t.canvas.frameInfo).toBeNull();
|
||
});
|
||
|
||
it('scales a shape from the opposite corner when a corner is pulled, as one undo step', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
t.pull(t.frame().corners[2]!, 20, 10);
|
||
expect(t.points('a')).toEqual([
|
||
[40, 40],
|
||
[80, 40],
|
||
[80, 70],
|
||
[40, 70],
|
||
]);
|
||
t.state.undo();
|
||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||
// The step before was none of the gesture's: the drawing has no further one to take back.
|
||
t.state.undo();
|
||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||
});
|
||
|
||
it('shows the pull while it runs, and its undo step leads back to the very rings that were there', () => {
|
||
const t = tab();
|
||
t.select('a', 'c');
|
||
const before = t.shape('c').rings;
|
||
const handle = t.frame().corners[2]!;
|
||
t.target.press(t.asset, pressAt(handle), handle);
|
||
t.target.move({ x: handle.x + 100 * t.zoom, y: handle.y });
|
||
// The preview waits for the frame; a test has none, so it is run by hand.
|
||
(t.canvas as unknown as { writePreview(): void }).writePreview();
|
||
expect(t.points('c')[1]).toEqual([240, 40]);
|
||
expect(t.points('a')[1]).toEqual([80, 40]);
|
||
t.target.release();
|
||
expect(t.points('c')[1]).toEqual([240, 40]);
|
||
t.state.undo();
|
||
expect(t.shape('c').rings).toBe(before);
|
||
});
|
||
|
||
it('scales in one direction from an edge', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
t.pull(t.frame().edges[3]!, -20, 33);
|
||
expect(t.points('a')).toEqual([
|
||
[20, 40],
|
||
[60, 40],
|
||
[60, 60],
|
||
[20, 60],
|
||
]);
|
||
});
|
||
|
||
it('scales every selected shape as one, grouped or not, and one undo puts all back', () => {
|
||
const t = tab();
|
||
t.select('a', 'c');
|
||
// The frame is 100 wide; pulled to 200 everything doubles from the left top corner.
|
||
t.pull(t.frame().corners[2]!, 100, 20);
|
||
expect(t.points('a')[2]).toEqual([80, 80]);
|
||
expect(t.points('c')[0]).toEqual([200, 40]);
|
||
expect(t.points('c')[2]).toEqual([240, 80]);
|
||
expect(t.points('b')[0]).toEqual([80, 40]);
|
||
t.state.undo();
|
||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||
expect(t.points('c')[0]).toEqual([120, 40]);
|
||
});
|
||
|
||
it('frames a whole group after a press on one of its shapes and scales it together', () => {
|
||
const t = tab();
|
||
t.state.execute(new GroupVectorShapesCommand(t.asset.id, ['a', 'b']));
|
||
const on = t.at(50, 50);
|
||
t.target.press(t.asset, pressAt(on), on);
|
||
t.target.release();
|
||
expect(t.state.selectedShapeIds).toEqual(['a', 'b']);
|
||
t.pull(t.frame().edges[1]!, 60, 0);
|
||
expect(t.points('a')[1]).toEqual([80, 40]);
|
||
expect(t.points('b')[0]).toEqual([120, 40]);
|
||
expect(t.points('b')[1]).toEqual([160, 40]);
|
||
});
|
||
|
||
it('keeps the proportions with Shift and scales about the middle with Alt', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
t.pull(t.frame().corners[2]!, 20, 4, 'shift');
|
||
expect(t.points('a')[2]).toEqual([80, 80]);
|
||
t.state.undo();
|
||
t.pull(t.frame().corners[2]!, 10, 10, 'alt');
|
||
expect(t.points('a')).toEqual([
|
||
[30, 30],
|
||
[70, 30],
|
||
[70, 70],
|
||
[30, 70],
|
||
]);
|
||
});
|
||
|
||
it('takes a Shift press on a handle itself instead of leaving it to the pan or the selection', () => {
|
||
const t = tab();
|
||
t.select('a', 'c');
|
||
const handle = t.frame().corners[2]!;
|
||
expect(t.target.shiftPresses(t.asset, handle)).toBe(true);
|
||
// The frame is 100 wide: pulled to 200 with Shift the height doubles along.
|
||
t.pull(handle, 100, 0, 'shift');
|
||
expect(t.state.selectedShapeIds).toEqual(['a', 'c']);
|
||
expect(t.points('c')[2]).toEqual([240, 80]);
|
||
// Beside the frame and the shapes Shift is still the pan's.
|
||
expect(t.target.shiftPresses(t.asset, t.at(200, 200))).toBe(false);
|
||
});
|
||
|
||
it('turns the selection round the middle of its frame with the knob', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
const knob = t.frame().knob;
|
||
const centre = t.at(50, 50);
|
||
const reach = centre.y - knob.y;
|
||
// From straight above the middle to straight right of it: a quarter turn clockwise.
|
||
t.target.press(t.asset, pressAt(knob), knob);
|
||
t.target.move({ x: centre.x + reach, y: centre.y });
|
||
expect(t.frame().knob.x).toBeCloseTo(centre.x + reach, 6);
|
||
expect(t.frame().knob.y).toBeCloseTo(centre.y, 6);
|
||
t.target.release();
|
||
expect(t.points('a')).toEqual([
|
||
[60, 40],
|
||
[60, 60],
|
||
[40, 60],
|
||
[40, 40],
|
||
]);
|
||
// Baked in: the frame is upright again and the knob back on top.
|
||
expect(t.frame().knob.x).toBeCloseTo(knob.x, 6);
|
||
expect(t.frame().knob.y).toBeCloseTo(knob.y, 6);
|
||
t.state.undo();
|
||
expect(t.points('a')[0]).toEqual([40, 40]);
|
||
});
|
||
|
||
it('snaps a turn to steps of fifteen degrees with Shift', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
const knob = t.frame().knob;
|
||
const centre = t.at(50, 50);
|
||
const reach = centre.y - knob.y;
|
||
const toward = (degrees: number): Vec2 => {
|
||
const r = ((degrees - 90) * Math.PI) / 180;
|
||
return { x: centre.x + reach * Math.cos(r), y: centre.y + reach * Math.sin(r) };
|
||
};
|
||
t.target.press(t.asset, pressAt(knob), knob);
|
||
t.target.move(toward(41), { shiftKey: true, altKey: false });
|
||
t.target.release();
|
||
const turned = rotationAbout({ x: 50, y: 50 }, 45);
|
||
expect(t.points('a')[0]![0]).toBeCloseTo(turned.a * 40 + turned.c * 40 + turned.tx, 3);
|
||
expect(t.points('a')[0]![1]).toBeCloseTo(turned.b * 40 + turned.d * 40 + turned.ty, 3);
|
||
});
|
||
|
||
it('leaves a point of the shape to itself: the frame never lies under it', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
const corner = t.at(60, 60);
|
||
t.target.press(t.asset, pressAt(corner), corner);
|
||
t.target.move({ x: corner.x + 10 * t.zoom, y: corner.y });
|
||
t.target.release();
|
||
expect(t.points('a')).toEqual([
|
||
[40, 40],
|
||
[60, 40],
|
||
[70, 60],
|
||
[40, 60],
|
||
]);
|
||
expect(t.canvas.pickedAt).toEqual({ ring: 0, index: 2 });
|
||
});
|
||
|
||
it('puts a pulled handle on the grid while snapping is on', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
t.canvas.setSnapping(true);
|
||
t.pull(t.frame().corners[2]!, 13, 2);
|
||
expect(t.points('a')[2]).toEqual([72, 64]);
|
||
});
|
||
|
||
it('makes no undo step of a press on a handle that pulls nothing', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
t.pull(t.frame().corners[2]!, 20, 20);
|
||
const handle = t.frame().corners[0]!;
|
||
t.target.press(t.asset, pressAt(handle), handle);
|
||
t.target.release();
|
||
t.state.undo();
|
||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||
});
|
||
|
||
it('takes the fill paint along and leaves the line as wide as it was', () => {
|
||
const t = tab();
|
||
const b = t.shape('b');
|
||
b.paint = { ...GRADIENT };
|
||
b.stroke = { color: 0, width: 3 };
|
||
t.select('b');
|
||
t.pull(t.frame().corners[2]!, 20, 20);
|
||
expect(b.paint).toMatchObject({ from: { x: 80, y: 60 }, to: { x: 120, y: 60 } });
|
||
expect(b.stroke.width).toBe(3);
|
||
t.state.undo();
|
||
expect(b.paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } });
|
||
});
|
||
|
||
it('shows the arrow of the handle under the pointer', () => {
|
||
const t = tab();
|
||
t.select('a');
|
||
t.target.move(t.frame().corners[2]!);
|
||
expect(t.canvas.cursor).toBe('nwse-resize');
|
||
t.target.move(t.frame().knob);
|
||
expect(t.canvas.cursor).toBe('grab');
|
||
t.target.move(t.at(50, 50));
|
||
expect(t.canvas.cursor).toBe('move');
|
||
});
|
||
});
|
||
|
||
describe('the TRANSFORM rows', () => {
|
||
const KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
|
||
const built = (...ids: string[]) => {
|
||
const r = rig();
|
||
r.state.select(shapeSelection(r.asset.id, ids));
|
||
r.build();
|
||
const row = (key: string) => r.widgets(NumberField)[KEYS.indexOf(key)]!;
|
||
const button = (label: string) => r.widgets(Button).find((b) => b.text === label)!;
|
||
const refresh = () => refreshShapeSection(r.host, r.asset.id, ids[0]!);
|
||
return { ...r, row, button, refresh };
|
||
};
|
||
|
||
it('show where the frame of the selection stands and how big it is', () => {
|
||
const r = built('a', 'c');
|
||
expect(KEYS.map((key) => Number(r.row(key).value))).toEqual([40, 40, 100, 20, 0]);
|
||
expect([...r.host.fields.keys()].slice(0, 5)).toEqual(KEYS);
|
||
});
|
||
|
||
it('move the whole selection to a typed place', () => {
|
||
const r = built('a', 'c');
|
||
r.row('frameX').onChange!('10');
|
||
r.row('frameY').onChange!('0');
|
||
expect(r.points('a')[0]).toEqual([10, 0]);
|
||
expect(r.points('c')[0]).toEqual([90, 0]);
|
||
expect(r.points('b')[0]).toEqual([80, 40]);
|
||
});
|
||
|
||
it('stretch the selection to a typed size from its left top corner', () => {
|
||
const r = built('a', 'c');
|
||
r.row('frameWidth').onChange!('200');
|
||
expect(r.points('c')[1]).toEqual([240, 40]);
|
||
r.row('frameHeight').onChange!('10');
|
||
expect(r.points('a')[2]).toEqual([80, 50]);
|
||
r.refresh();
|
||
expect(Number(r.row('frameWidth').value)).toBe(200);
|
||
expect(Number(r.row('frameHeight').value)).toBe(10);
|
||
});
|
||
|
||
it('turn by the typed degrees, and read 0 again once they are baked in', () => {
|
||
const r = built('a');
|
||
r.row('frameTurn').onChange!('90');
|
||
expect(r.points('a')[0]).toEqual([60, 40]);
|
||
r.refresh();
|
||
expect(Number(r.row('frameTurn').value)).toBe(0);
|
||
r.state.undo();
|
||
expect(r.points('a')[0]).toEqual([40, 40]);
|
||
});
|
||
|
||
it('preview a dragged number from the shapes as they were, and commit one undo step', () => {
|
||
const r = built('a');
|
||
const before = r.shape('a').rings;
|
||
const turn = r.row('frameTurn');
|
||
turn.onInput!('30');
|
||
turn.onInput!('60');
|
||
turn.onInput!('90');
|
||
// Each preview starts from the square as it was: three steps of thirty would have added rounding.
|
||
expect(r.points('a')[0]).toEqual([60, 40]);
|
||
r.refresh();
|
||
expect(Number(turn.value)).toBe(90);
|
||
turn.onChange!('180');
|
||
expect(r.points('a')[0]).toEqual([60, 60]);
|
||
r.refresh();
|
||
expect(Number(turn.value)).toBe(0);
|
||
r.state.undo();
|
||
expect(r.shape('a').rings).toBe(before);
|
||
});
|
||
|
||
it('mirror the selection about the middle of its frame', () => {
|
||
const r = built('a', 'c');
|
||
r.button('Flip horizontal').onClick!();
|
||
expect(r.points('a')[0]).toEqual([140, 40]);
|
||
expect(r.points('c')[0]).toEqual([60, 40]);
|
||
r.button('Flip vertical').onClick!();
|
||
expect(r.points('a')[0]).toEqual([140, 60]);
|
||
r.state.undo();
|
||
r.state.undo();
|
||
expect(r.points('a')[0]).toEqual([40, 40]);
|
||
expect(flipSelection(r.state, r.asset.id, ['nobody'], 'x')).toBe(false);
|
||
});
|
||
|
||
it('are not there for a shape that has no size yet, and come with its second point', () => {
|
||
const r = rig();
|
||
r.shape('a').rings = [{ closed: false, points: [{ x: 5, y: 5 }] }];
|
||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||
r.build();
|
||
expect(r.host.fields.has('frameX')).toBe(false);
|
||
const bare = shapeSignature(r.state.project, r.asset.id, 'a', ['a']);
|
||
r.shape('a').rings = [
|
||
{
|
||
closed: false,
|
||
points: [
|
||
{ x: 5, y: 5 },
|
||
{ x: 9, y: 5 },
|
||
],
|
||
},
|
||
];
|
||
expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).not.toBe(bare);
|
||
});
|
||
});
|
||
|
||
describe('shapes dragged together (#217)', () => {
|
||
it('snaps the offset all of them share, so each keeps its place beside the others', () => {
|
||
const t = tab();
|
||
t.shape('a').rings = square('a', 43, 41).rings;
|
||
t.select('a', 'c');
|
||
t.canvas.setSnapping(true);
|
||
const on = t.at(130, 50);
|
||
t.target.press(t.asset, pressAt(on), on);
|
||
t.target.move({ x: on.x + 13 * t.zoom, y: on.y + 5 * t.zoom });
|
||
t.target.release();
|
||
// 13 and 5 go to the grid's 16 and 8 once, for both: a stays three pixels off the grid.
|
||
expect(t.points('c')[0]).toEqual([136, 48]);
|
||
expect(t.points('a')[0]).toEqual([59, 49]);
|
||
});
|
||
|
||
it('takes the paints of every dragged shape along, and one undo puts them back', () => {
|
||
const t = tab();
|
||
t.shape('b').paint = { ...GRADIENT };
|
||
const c = t.shape('c');
|
||
c.stroke = { color: 0, width: 2, paint: { ...GRADIENT, from: { x: 120, y: 50 }, to: { x: 140, y: 50 } } };
|
||
t.select('b', 'c');
|
||
// On the grid, so the offset is a whole 8 and 16 and not what a screen pixel divides to.
|
||
t.canvas.setSnapping(true);
|
||
const on = t.at(130, 50);
|
||
t.target.press(t.asset, pressAt(on), on);
|
||
t.target.move({ x: on.x + 8 * t.zoom, y: on.y + 16 * t.zoom });
|
||
t.target.release();
|
||
expect(t.shape('b').paint).toMatchObject({ from: { x: 88, y: 66 }, to: { x: 108, y: 66 } });
|
||
expect(c.stroke.paint).toMatchObject({ from: { x: 128, y: 66 }, to: { x: 148, y: 66 } });
|
||
t.state.undo();
|
||
expect(t.shape('b').paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } });
|
||
expect(c.stroke.paint).toMatchObject({ from: { x: 120, y: 50 }, to: { x: 140, y: 50 } });
|
||
});
|
||
});
|