Put the shapes of a vector drawing on layers and in groups (#190)
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 7h24m17s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 23:13:43 +02:00
1 parent 393caadc9d
commit 2ff3a78607
30 files changed
+2625 -408

No files matched your search

+1 -1
View File
@@ -45,7 +45,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, 1639 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1645 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
+2 -2
View File
@@ -63,9 +63,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'rigidbody' }; return { ...base, type: 'rigidbody' };
case 'container': case 'container':
return { ...base, type: 'container', objects: [] }; return { ...base, type: 'container', objects: [] };
// A drawing starts empty: the pen tool of #129 sets the first shape down. // A drawing starts empty: the pen tool of #129 sets the first shape down, on its one layer.
case 'vectorart': case 'vectorart':
return { ...base, type: 'vectorart', shapes: [] }; return { ...base, type: 'vectorart', layers: [{ id: newId(), name: 'Layer 1', items: [] }] };
// A canvas with nothing to paint on is useless, so it starts with one layer on one frame. // A canvas with nothing to paint on is useless, so it starts with one layer on one frame.
case 'pixelart': case 'pixelart':
return { return {
+9
View File
@@ -12,10 +12,12 @@ import type {
VariableOwnerAsset, VariableOwnerAsset,
VariablesAsset, VariablesAsset,
VectorArtAsset, VectorArtAsset,
VectorItem,
VectorShape, VectorShape,
} from '../model/Asset'; } from '../model/Asset';
import { findLayer } from '../model/tilemap'; import { findLayer } from '../model/tilemap';
import { findShape } from '../model/vectorart'; import { findShape } from '../model/vectorart';
import { findVectorItem } from '../model/vectorTree';
import { findObject, type ObjectTree } from '../model/hierarchy'; import { findObject, type ObjectTree } from '../model/hierarchy';
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import { import {
@@ -126,6 +128,13 @@ export function requireVectorShape(project: Project, assetId: string, shapeId: s
return shape; return shape;
} }
/** A shape or a group of the drawing (#190). */
export function requireVectorItem(project: Project, assetId: string, itemId: string): VectorItem {
const item = findVectorItem(requireVectorArt(project, assetId), itemId);
if (!item) throw new CommandError(`Shape ${itemId} not found`);
return item;
}
export function requireCurve(project: Project, assetId: string): CurveAsset { export function requireCurve(project: Project, assetId: string): CurveAsset {
const asset = findAssetOf(project, assetId, 'curve'); const asset = findAssetOf(project, assetId, 'curve');
if (!asset) throw new CommandError(`Curve ${assetId} not found`); if (!asset) throw new CommandError(`Curve ${assetId} not found`);
+244 -104
View File
@@ -9,124 +9,179 @@
* `model/vectorart.ts`), so a command that edits a shape cannot forget to say that it did. * `model/vectorart.ts`), so a command that edits a shape cannot forget to say that it did.
*/ */
import { newId } from '../ids'; import { newId } from '../ids';
import type { VectorPaint, VectorRing, VectorShape, VectorStroke } from '../model/Asset'; import type {
VectorItem,
VectorLayer,
VectorPaint,
VectorRing,
VectorShape,
VectorStroke,
} from '../model/Asset';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart'; import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean'; import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint'; import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list'; import {
allShapes,
isVectorGroup,
placeOf,
type ShapeOrderMove,
type TreeDrop,
VectorTreeError,
withGroupDissolved,
withItemAdded,
withItemOrdered,
withItemsGrouped,
withItemsMoved,
withItemsRemoved,
withLayerAdded,
withLayerChanged,
withLayerMoved,
withLayerRemoved,
} from '../model/vectorTree';
import { copyName } from '../util/names'; import { copyName } from '../util/names';
import { type Command, CommandError, MoveCommand } from './Command'; import { type Command, CommandError } from './Command';
import { nextNumberedName } from './naming'; import { nextNumberedName } from './naming';
import { requireVectorArt, requireVectorShape } from './require'; import { requireVectorArt, requireVectorItem, requireVectorShape } from './require';
/**
* A change to the layers and the tree in them (#190): which shape stands where, in which group, on
* which layer. `edit` is one of the functions of `model/vectorTree.ts`; it hands back new layers, so
* the undo is simply the layers that were there.
*/
export class EditVectorTreeCommand implements Command {
private previous: VectorLayer[] = [];
constructor(
protected readonly assetId: string,
readonly label: string,
private readonly edit: (layers: readonly VectorLayer[]) => VectorLayer[],
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
try {
const next = this.edit(asset.layers);
this.previous = asset.layers;
asset.layers = next;
} catch (error) {
if (error instanceof VectorTreeError) throw new CommandError(error.message);
throw error;
}
}
undo(project: Project): void {
requireVectorArt(project, this.assetId).layers = this.previous;
}
}
export class AddVectorShapeCommand implements Command { export class AddVectorShapeCommand implements Command {
readonly label = 'Add shape'; readonly label = 'Add shape';
readonly shapeId = newId(); readonly shapeId = newId();
private readonly tree: EditVectorTreeCommand;
/** `rings` are what the pen tool drew; a shape added from the list starts without a ring. */ /**
constructor( * `rings` are what the pen tool drew; a shape added from the list starts without a ring. It lands
private readonly assetId: string, * on top of the layer `layerId`, and of the topmost layer without one.
private readonly rings: readonly VectorRing[] = [], */
private readonly fill = SHAPE_FILL_DEFAULT, constructor(assetId: string, rings: readonly VectorRing[] = [], fill = SHAPE_FILL_DEFAULT, layerId = '') {
) {} let shape: VectorShape | null = null;
this.tree = new EditVectorTreeCommand(assetId, this.label, (layers) => {
execute(project: Project): void { shape ??= {
const asset = requireVectorArt(project, this.assetId); id: this.shapeId,
const name = nextNumberedName( name: nextNumberedName(namesIn(layers, false), 'Shape'),
asset.shapes.map((shape) => shape.name), rings: cloneRings(rings),
'Shape', fill,
); opacity: 1,
asset.shapes.push({ };
id: this.shapeId, return withItemAdded(layers, layerId || topLayerId(layers), shape);
name,
rings: cloneRings(this.rings),
fill: this.fill,
opacity: 1,
}); });
} }
execute(project: Project): void {
this.tree.execute(project);
}
undo(project: Project): void { undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId); this.tree.undo(project);
asset.shapes = removeById(asset.shapes, this.shapeId);
} }
} }
/** A copy of a shape right above it, on the same spot, with its outlines, paints and opacity. */ function topLayerId(layers: readonly VectorLayer[]): string {
return layers[layers.length - 1]?.id ?? '';
}
/** The names of all shapes, or of all groups, so a new one gets a number nobody has. */
function namesIn(layers: readonly VectorLayer[], groups: boolean): string[] {
const walk = (items: readonly VectorItem[]): string[] =>
items.flatMap((item) =>
isVectorGroup(item) ? [...(groups ? [item.name] : []), ...walk(item.items)] : groups ? [] : [item.name],
);
return layers.flatMap((layer) => walk(layer.items));
}
/**
* A copy of a shape right above it, on the same spot, with its outlines, paints and opacity. A group
* is copied with all that is in it.
*/
export class DuplicateVectorShapeCommand implements Command { export class DuplicateVectorShapeCommand implements Command {
readonly label = 'Duplicate shape'; readonly label: string = 'Duplicate shape';
readonly shapeId = newId(); readonly shapeId = newId();
private readonly tree: EditVectorTreeCommand;
constructor( constructor(assetId: string, sourceId: string) {
private readonly assetId: string, let copy: VectorItem | null = null;
private readonly sourceId: string, this.tree = new EditVectorTreeCommand(assetId, this.label, (layers) => {
) {} const place = placeOf(layers, sourceId);
if (!place) throw new VectorTreeError(`Shape ${sourceId} not found`);
const group = isVectorGroup(place.item);
copy ??= {
...copyOf(place.item),
id: this.shapeId,
name: copyName(namesIn(layers, group), place.item.name),
};
return withItemAdded(layers, place.layer.id, copy, {
parentId: place.parent?.id ?? null,
belowId: sourceId,
});
});
}
execute(project: Project): void { execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId); this.tree.execute(project);
const index = asset.shapes.findIndex((shape) => shape.id === this.sourceId);
const source = asset.shapes[index];
if (!source) throw new CommandError(`Shape ${this.sourceId} not found`);
const copy: VectorShape = {
...source,
id: this.shapeId,
name: copyName(
asset.shapes.map((shape) => shape.name),
source.name,
),
rings: cloneRings(source.rings),
};
if (source.paint) copy.paint = clonePaint(source.paint);
if (source.stroke) copy.stroke = cloneStroke(source.stroke);
asset.shapes.splice(index + 1, 0, copy);
} }
undo(project: Project): void { undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId); this.tree.undo(project);
asset.shapes = removeById(asset.shapes, this.shapeId);
} }
} }
/** Takes a shape out; unlike a tilemap layer the last one may go – an empty drawing is a drawing. */ /** A deep copy under new ids; the caller names the copy itself. */
export class RemoveVectorShapeCommand implements Command { function copyOf(item: VectorItem): VectorItem {
readonly label = 'Remove shape'; if (isVectorGroup(item)) return { id: newId(), name: item.name, items: item.items.map(copyOf) };
private removed: Removed<VectorShape> | undefined; const copy: VectorShape = { ...item, id: newId(), rings: cloneRings(item.rings) };
if (item.paint) copy.paint = clonePaint(item.paint);
if (item.stroke) copy.stroke = cloneStroke(item.stroke);
return copy;
}
constructor( /**
private readonly assetId: string, * Takes a shape out, or a group with all that is in it. Unlike a tilemap layer the last one may go –
private readonly shapeId: string, * an empty drawing is a drawing.
) {} */
export class RemoveVectorShapeCommand extends EditVectorTreeCommand {
execute(project: Project): void { constructor(assetId: string, shapeId: string) {
const asset = requireVectorArt(project, this.assetId); super(assetId, 'Remove shape', (layers) => {
const removed = removeFirst(asset.shapes, (shape) => shape.id === this.shapeId); if (!placeOf(layers, shapeId)) throw new VectorTreeError(`Shape ${shapeId} not found`);
if (!removed) throw new CommandError(`Shape ${this.shapeId} not found`); return withItemsRemoved(layers, [shapeId]);
this.removed = removed; });
}
undo(project: Project): void {
if (this.removed) restoreAt(requireVectorArt(project, this.assetId).shapes, [this.removed]);
} }
} }
/** Takes several shapes out in one step – Delete on a selection of more than one (#187). */ /** Takes several shapes out in one step – Delete on a selection of more than one (#187). */
export class RemoveVectorShapesCommand implements Command { export class RemoveVectorShapesCommand extends EditVectorTreeCommand {
readonly label = 'Remove shapes'; constructor(assetId: string, shapeIds: readonly string[]) {
private removed: Removed<VectorShape>[] = []; super(assetId, 'Remove shapes', (layers) => withItemsRemoved(layers, shapeIds));
constructor(
private readonly assetId: string,
private readonly shapeIds: readonly string[],
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
this.removed = removeWhere(asset.shapes, (shape) => this.shapeIds.includes(shape.id));
if (this.removed.length === 0) throw new CommandError('No such shape');
}
undo(project: Project): void {
restoreAt(requireVectorArt(project, this.assetId).shapes, this.removed);
} }
} }
@@ -151,7 +206,7 @@ const NOTHING_LEFT: Record<ShapeBoolean, string> = {
*/ */
export class CombineVectorShapesCommand implements Command { export class CombineVectorShapesCommand implements Command {
readonly label: string; readonly label: string;
private removed: Removed<VectorShape>[] = []; private previousLayers: VectorLayer[] = [];
private previous: VectorRing[] = []; private previous: VectorRing[] = [];
private resultId = ''; private resultId = '';
@@ -170,26 +225,29 @@ export class CombineVectorShapesCommand implements Command {
execute(project: Project): void { execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId); const asset = requireVectorArt(project, this.assetId);
const shapes = asset.shapes.filter((shape) => this.shapeIds.includes(shape.id)); const shapes = allShapes(asset).filter((shape) => this.shapeIds.includes(shape.id));
if (shapes.length !== new Set(this.shapeIds).size) throw new CommandError('No such shape'); if (shapes.length !== new Set(this.shapeIds).size) throw new CommandError('No such shape');
const blocked = combineBlocker(shapes); const blocked = combineBlocker(shapes);
if (blocked) throw new CommandError(blocked); if (blocked) throw new CommandError(blocked);
const rings = combinedRings(shapes, this.op); const rings = combinedRings(shapes, this.op);
if (rings.length === 0) throw new CommandError(NOTHING_LEFT[this.op]); if (rings.length === 0) throw new CommandError(NOTHING_LEFT[this.op]);
const base = shapes[0]!; const base = shapes[0]!;
const others = shapes.slice(1).map((shape) => shape.id);
this.resultId = base.id; this.resultId = base.id;
this.previous = base.rings; this.previous = base.rings;
this.previousLayers = asset.layers;
base.rings = rings; base.rings = rings;
this.removed = removeWhere(asset.shapes, (shape) => shape !== base && this.shapeIds.includes(shape.id)); asset.layers = withItemsRemoved(asset.layers, others);
} }
undo(project: Project): void { undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId); const asset = requireVectorArt(project, this.assetId);
restoreAt(asset.shapes, this.removed); asset.layers = this.previousLayers;
requireVectorShape(project, this.assetId, this.resultId).rings = this.previous; requireVectorShape(project, this.assetId, this.resultId).rings = this.previous;
} }
} }
/** Renames a shape or a group. */
export class RenameVectorShapeCommand implements Command { export class RenameVectorShapeCommand implements Command {
readonly label = 'Rename shape'; readonly label = 'Rename shape';
private previous = ''; private previous = '';
@@ -201,31 +259,113 @@ export class RenameVectorShapeCommand implements Command {
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const shape = requireVectorShape(project, this.assetId, this.shapeId); const item = requireVectorItem(project, this.assetId, this.shapeId);
const name = this.name.trim(); const name = this.name.trim();
if (!name) throw new CommandError('A shape needs a name'); if (!name) throw new CommandError(`A ${isVectorGroup(item) ? 'group' : 'shape'} needs a name`);
this.previous = shape.name; this.previous = item.name;
shape.name = name; item.name = name;
} }
undo(project: Project): void { undo(project: Project): void {
requireVectorShape(project, this.assetId, this.shapeId).name = this.previous; requireVectorItem(project, this.assetId, this.shapeId).name = this.previous;
} }
} }
/** Moves a shape in the drawing order; the last shape in the list is drawn on top. */ /** One of the four order buttons: a step, or all the way, among the items of the same list. */
export class MoveVectorShapeCommand extends MoveCommand { export class OrderVectorShapeCommand extends EditVectorTreeCommand {
constructor( constructor(assetId: string, shapeId: string, move: ShapeOrderMove) {
private readonly assetId: string, super(assetId, 'Reorder shapes', (layers) => {
from: number, const next = withItemOrdered(layers, shapeId, move);
to: number, if (!next) throw new VectorTreeError('It is there already');
) { return next;
super('Reorder shapes', from, to); });
} }
}
protected move(project: Project, from: number, to: number): void { /**
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to)) * Moves shapes and groups to another place in the tree (#190): into a group, out of one, onto
throw new CommandError('No such shape'); * another layer. Without a `drop` they land on top of the layer.
*/
export class MoveVectorShapesCommand extends EditVectorTreeCommand {
constructor(assetId: string, shapeIds: readonly string[], layerId: string, drop: TreeDrop | null = null) {
super(assetId, shapeIds.length === 1 ? 'Move shape' : 'Move shapes', (layers) =>
withItemsMoved(layers, shapeIds, layerId, drop),
);
}
}
/** Puts shapes and groups into a new group, where the topmost of them stood (#190). */
export class GroupVectorShapesCommand extends EditVectorTreeCommand {
constructor(
assetId: string,
shapeIds: readonly string[],
readonly groupId = newId(),
) {
let name = '';
super(assetId, 'Group shapes', (layers) => {
name ||= nextNumberedName(namesIn(layers, true), 'Group');
return withItemsGrouped(layers, shapeIds, { id: groupId, name });
});
}
}
/** Takes a group away; what was in it stays where the group stood. */
export class UngroupVectorShapesCommand extends EditVectorTreeCommand {
constructor(assetId: string, groupIds: readonly string[]) {
super(assetId, 'Ungroup shapes', (layers) =>
groupIds.reduce<VectorLayer[]>((next, id) => withGroupDissolved(next, id), [...layers]),
);
}
}
export class AddVectorLayerCommand extends EditVectorTreeCommand {
/** The new layer stands right over `overId`, and on top of all without one. */
constructor(
assetId: string,
overId = '',
readonly layerId = newId(),
) {
let name = '';
super(assetId, 'Add layer', (layers) => {
name ||= nextNumberedName(
layers.map((layer) => layer.name),
'Layer',
);
return withLayerAdded(layers, { id: layerId, name, items: [] }, overId);
});
}
}
/** Takes a layer out with all that is drawn on it; the last one stays. */
export class RemoveVectorLayerCommand extends EditVectorTreeCommand {
constructor(assetId: string, layerId: string) {
super(assetId, 'Remove layer', (layers) => withLayerRemoved(layers, layerId));
}
}
/** Moves a layer in the stack; the last one is drawn on top. */
export class MoveVectorLayerCommand extends EditVectorTreeCommand {
constructor(assetId: string, from: number, to: number) {
super(assetId, 'Reorder layers', (layers) => withLayerMoved(layers, from, to));
}
}
export class RenameVectorLayerCommand extends EditVectorTreeCommand {
constructor(assetId: string, layerId: string, name: string) {
super(assetId, 'Rename layer', (layers) => {
const trimmed = name.trim();
if (!trimmed) throw new VectorTreeError('A layer needs a name');
return withLayerChanged(layers, layerId, { name: trimmed });
});
}
}
/** The eye of a layer: hidden, it is neither in the tab nor in the picture. */
export class SetVectorLayerHiddenCommand extends EditVectorTreeCommand {
constructor(assetId: string, layerId: string, hidden: boolean) {
super(assetId, hidden ? 'Hide layer' : 'Show layer', (layers) =>
withLayerChanged(layers, layerId, { hidden }),
);
} }
} }
+26 -2
View File
@@ -400,7 +400,7 @@ export type VectorPaint = VectorGradient | VectorTextureFill;
/** /**
* One filled figure. `fill` is a solid colour and `opacity` scales it; a `paint` (#186) fills with a * One filled figure. `fill` is a solid colour and `opacity` scales it; a `paint` (#186) fills with a
* gradient or a picture instead, and `fill` stays what the shape goes back to. The list order is the * gradient or a picture instead, and `fill` stays what the shape goes back to. The list order is the
* drawing order: the last shape of the asset lies on top, as the last layer of a tilemap does. * drawing order: the last item of a list lies on top, as the last layer of a tilemap does.
*/ */
export interface VectorShape { export interface VectorShape {
id: string; id: string;
@@ -417,10 +417,34 @@ export interface VectorShape {
* is a property, and handed to the picture path through the generated-picture key of #185 – so a * is a property, and handed to the picture path through the generated-picture key of #185 – so a
* sprite, a tile, a tilemap and a particle source wear it exactly as they wear a texture, and the * sprite, a tile, a tilemap and a particle source wear it exactly as they wear a texture, and the
* shapes never leave this asset. The shapes are intrinsic data, as a texture's frames are. * shapes never leave this asset. The shapes are intrinsic data, as a texture's frames are.
*
* They stand in layers (#190), the last layer on top, and a drawing always has one. How the tree is
* read and edited is `model/vectorTree.ts`.
*/ */
export interface VectorArtAsset extends AssetBase { export interface VectorArtAsset extends AssetBase {
type: 'vectorart'; type: 'vectorart';
shapes: VectorShape[]; layers: VectorLayer[];
}
/**
* Shapes and further groups that belong together (#190). It has no place or size of its own: its
* shapes stay in canvas pixels, and moving the group moves them. A group is never empty – the one
* that loses its last item goes with it.
*/
export interface VectorGroup {
id: string;
name: string;
items: VectorItem[];
}
export type VectorItem = VectorShape | VectorGroup;
/** One layer of a drawing (#190): a tree of shapes and groups. Absent `hidden` is shown. */
export interface VectorLayer {
id: string;
name: string;
hidden?: boolean;
items: VectorItem[];
} }
/** /**
+3 -2
View File
@@ -22,6 +22,7 @@ import type { PropertyBag, Vec2 } from './Properties';
import type { TextureRef } from './texture'; import type { TextureRef } from './texture';
import { isDrawableRing, vectorCanvasSize } from './vectorart'; import { isDrawableRing, vectorCanvasSize } from './vectorart';
import { sortedStops, textureFillRef } from './vectorPaint'; import { sortedStops, textureFillRef } from './vectorPaint';
import { drawnShapes } from './vectorTree';
/** One step along a path. `close` goes back to where the subpath began, in a straight line. */ /** One step along a path. `close` goes back to where the subpath began, in a straight line. */
export type PathOp = export type PathOp =
@@ -59,10 +60,10 @@ export interface ShapeDrawing {
strokePaint: DrawPaint | null; strokePaint: DrawPaint | null;
} }
/** What the asset draws, back to front – the last shape of the list lies on top. */ /** What the asset draws, back to front – the last shape lies on top; a hidden layer draws nothing. */
export function vectorArtDrawings(asset: VectorArtAsset, project?: Project): ShapeDrawing[] { export function vectorArtDrawings(asset: VectorArtAsset, project?: Project): ShapeDrawing[] {
const drawings: ShapeDrawing[] = []; const drawings: ShapeDrawing[] = [];
for (const shape of asset.shapes) { for (const shape of drawnShapes(asset)) {
const drawing = shapeDrawing(shape, project); const drawing = shapeDrawing(shape, project);
if (drawing) drawings.push(drawing); if (drawing) drawings.push(drawing);
} }
+469
View File
@@ -0,0 +1,469 @@
/**
* The layers of a vector art asset and the tree of shapes and groups in each (#190): how it is read,
* and every way it changes.
*
* The edits here take the layers and hand back new ones. No list is changed in place, so a command
* keeps the layers it found and puts them back on undo, whatever the edit did to them. The shapes
* themselves are passed along as they are – a point dragged afterwards is still the same shape.
*/
import type { VectorArtAsset, VectorGroup, VectorItem, VectorLayer, VectorShape } from './Asset';
export class VectorTreeError extends Error {}
/** Where an item lands when it is moved in the drawing order (the user's four buttons of #129). */
export type ShapeOrderMove = 'forward' | 'front' | 'backward' | 'back';
/**
* The index `itemId` moves to, or -1 when it is already there or not in the list. The list order is
* the drawing order and the last item lies on top, so "forward" is one step towards the end.
*/
export function shapeOrderTarget(
items: readonly { id: string }[],
itemId: string,
move: ShapeOrderMove,
): number {
const from = items.findIndex((item) => item.id === itemId);
if (from < 0) return -1;
const last = items.length - 1;
const to =
move === 'forward'
? Math.min(from + 1, last)
: move === 'front'
? last
: move === 'backward'
? Math.max(from - 1, 0)
: 0;
return to === from ? -1 : to;
}
export function isVectorGroup(item: VectorItem): item is VectorGroup {
return 'items' in item;
}
/** The shapes under `items` in drawing order, the last one on top. */
export function shapesIn(items: readonly VectorItem[]): VectorShape[] {
return items.flatMap((item) => (isVectorGroup(item) ? shapesIn(item.items) : [item]));
}
/** Every shape of the drawing in drawing order, those of hidden layers included. */
export function allShapes(asset: VectorArtAsset): VectorShape[] {
return asset.layers.flatMap((layer) => shapesIn(layer.items));
}
/** What is drawn: the shapes of the layers that are shown. */
export function drawnShapes(asset: VectorArtAsset): VectorShape[] {
return asset.layers.flatMap((layer) => (layer.hidden ? [] : shapesIn(layer.items)));
}
/** Where an item stands: its layer, the group it is in (null at the top of the layer) and its place there. */
export interface ItemPlace {
layer: VectorLayer;
parent: VectorGroup | null;
siblings: VectorItem[];
index: number;
item: VectorItem;
/** How many groups it is inside of. */
depth: number;
}
export function placeOf(layers: readonly VectorLayer[], itemId: string): ItemPlace | null {
for (const layer of layers) {
const place = placeIn(layer, null, layer.items, itemId, 0);
if (place) return place;
}
return null;
}
function placeIn(
layer: VectorLayer,
parent: VectorGroup | null,
siblings: VectorItem[],
itemId: string,
depth: number,
): ItemPlace | null {
for (const [index, item] of siblings.entries()) {
if (item.id === itemId) return { layer, parent, siblings, index, item, depth };
const below = isVectorGroup(item) ? placeIn(layer, item, item.items, itemId, depth + 1) : null;
if (below) return below;
}
return null;
}
export function findVectorItem(asset: VectorArtAsset, itemId: string): VectorItem | undefined {
return placeOf(asset.layers, itemId)?.item;
}
export function layerOfItem(asset: VectorArtAsset, itemId: string): VectorLayer | undefined {
return placeOf(asset.layers, itemId)?.layer;
}
/** The outermost group a shape is in; null for a shape that stands in its layer directly. */
export function outermostGroup(layers: readonly VectorLayer[], itemId: string): VectorGroup | null {
for (const layer of layers)
for (const item of layer.items)
if (isVectorGroup(item) && item.id !== itemId && placeIn(layer, item, item.items, itemId, 1))
return item;
return null;
}
/**
* What a selection of shapes stands for in the tree: a group all of whose shapes are selected counts
* as itself, the outermost such group first, and every other selected shape as itself. In drawing order.
*/
export function selectedItems(layers: readonly VectorLayer[], shapeIds: readonly string[]): string[] {
const picked = new Set(shapeIds);
const walk = (items: readonly VectorItem[]): string[] =>
items.flatMap((item) => {
if (!isVectorGroup(item)) return picked.has(item.id) ? [item.id] : [];
const shapes = shapesIn(item.items);
return shapes.length > 0 && shapes.every((shape) => picked.has(shape.id))
? [item.id]
: walk(item.items);
});
return layers.flatMap((layer) => walk(layer.items));
}
/** One row of the tree as a list shows it. */
export interface TreeRow {
id: string;
name: string;
depth: number;
group: boolean;
}
/**
* A layer's tree as rows, top first: the item drawn last is the first row, and a group stands over
* what is in it.
*/
export function treeRows(items: readonly VectorItem[], depth = 0): TreeRow[] {
const rows: TreeRow[] = [];
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]!;
const group = isVectorGroup(item);
rows.push({ id: item.id, name: item.name, depth, group });
if (group) rows.push(...treeRows(item.items, depth + 1));
}
return rows;
}
/** A place in a layer's tree: in which group (null is the layer itself) and over which sibling. */
export interface TreeDrop {
parentId: string | null;
/** The sibling it is drawn right over – the row shown under it. Null puts it lowest in its list. */
belowId: string | null;
}
/**
* The place a row dragged into `gap` lands in, or null when that is no place for it: where it already
* is, or inside itself. `gap` counts the boundaries between rows, 0 being above the first. Between a
* deep row and a shallower one the gap belongs to several lists at once – the end of the inner group,
* or the outer list after it – and `depth`, read from how far right the pointer is, says which.
*/
export function treeDrop(
rows: readonly TreeRow[],
dragged: string,
gap: number,
depth: number,
): TreeDrop | null {
const landing = landingOf(rows, dragged, gap, depth);
if (!landing) return null;
const { rest, at, level } = landing;
const parentId = level === 0 ? null : ancestorAt(rest, at - 1, level - 1);
if (level > 0 && parentId === null) return null;
const belowId = nextAtLevel(rest, at, level);
const was = rowPlace(
rows,
rows.findIndex((row) => row.id === dragged),
);
return was.parentId === parentId && was.belowId === belowId ? null : { parentId, belowId };
}
/** How deep that drop lands – where a list draws its marker; null where `treeDrop` has no place. */
export function treeDropDepth(
rows: readonly TreeRow[],
dragged: string,
gap: number,
depth: number,
): number | null {
return treeDrop(rows, dragged, gap, depth) ? (landingOf(rows, dragged, gap, depth)?.level ?? null) : null;
}
/** The rows without the dragged ones – the model takes them out first – and where the gap is in them. */
function landingOf(
rows: readonly TreeRow[],
dragged: string,
gap: number,
depth: number,
): { rest: TreeRow[]; at: number; level: number } | null {
const from = rows.findIndex((row) => row.id === dragged);
if (from < 0) return null;
const end = subtreeEnd(rows, from);
if (gap > from && gap < end) return null;
const rest = [...rows.slice(0, from), ...rows.slice(end)];
const at = Math.max(0, Math.min(rest.length, gap <= from ? gap : gap - (end - from)));
const above = rest[at - 1];
const below = rest[at];
// A group's rows follow it one step deeper; a gap right under a group is its topmost place.
const deepest = above ? above.depth + (above.group ? 1 : 0) : 0;
const shallowest = below ? below.depth : 0;
return { rest, at, level: Math.max(shallowest, Math.min(deepest, Math.round(depth))) };
}
/** The first row at `level` from `index` on, before the list at that level ends. */
function nextAtLevel(rows: readonly TreeRow[], index: number, level: number): string | null {
for (let i = index; i < rows.length; i++) {
const row = rows[i]!;
if (row.depth < level) return null;
if (row.depth === level) return row.id;
}
return null;
}
/** One past the last row of the subtree that starts at `index`. */
function subtreeEnd(rows: readonly TreeRow[], index: number): number {
const depth = rows[index]!.depth;
let end = index + 1;
while (end < rows.length && rows[end]!.depth > depth) end += 1;
return end;
}
/** The group at `depth` that the row at `index` is in, or is. */
function ancestorAt(rows: readonly TreeRow[], index: number, depth: number): string | null {
for (let i = index; i >= 0; i--) {
const row = rows[i]!;
if (row.depth === depth) return row.group ? row.id : null;
if (row.depth < depth) return null;
}
return null;
}
function rowPlace(rows: readonly TreeRow[], index: number): TreeDrop {
const depth = rows[index]!.depth;
const parentId = depth === 0 ? null : ancestorAt(rows, index - 1, depth - 1);
return { parentId, belowId: nextAtLevel(rows, subtreeEnd(rows, index), depth) };
}
// ---------------------------------------------------------------------------------------------
// Edits. Each returns new layers and leaves the given ones as they were.
// ---------------------------------------------------------------------------------------------
/** Rebuilds every list through `edit`, innermost first; a group left empty goes. */
function mapLists(
layers: readonly VectorLayer[],
edit: (items: VectorItem[], owner: VectorLayer | VectorGroup) => VectorItem[],
): VectorLayer[] {
const rebuilt = (items: readonly VectorItem[], owner: VectorLayer | VectorGroup): VectorItem[] => {
const next: VectorItem[] = [];
for (const item of items) {
if (!isVectorGroup(item)) next.push(item);
else {
const inner = rebuilt(item.items, item);
if (inner.length > 0) next.push({ ...item, items: inner });
}
}
return edit(next, owner);
};
return layers.map((layer) => ({ ...layer, items: rebuilt(layer.items, layer) }));
}
function requirePlace(layers: readonly VectorLayer[], itemId: string): ItemPlace {
const place = placeOf(layers, itemId);
if (!place) throw new VectorTreeError(`Shape ${itemId} not found`);
return place;
}
function requireLayer(layers: readonly VectorLayer[], layerId: string): VectorLayer {
const layer = layers.find((l) => l.id === layerId);
if (!layer) throw new VectorTreeError(`Layer ${layerId} not found`);
return layer;
}
/** `moved` put into `items`: right over `drop.belowId`, lowest when that is null, on top without a drop. */
function placedIn(
items: readonly VectorItem[],
moved: readonly VectorItem[],
drop: TreeDrop | null,
): VectorItem[] {
if (!drop) return [...items, ...moved];
const at = drop.belowId === null ? -1 : items.findIndex((other) => other.id === drop.belowId);
return [...items.slice(0, at + 1), ...moved, ...items.slice(at + 1)];
}
/** Puts `item` into the layer: at `drop`, or on top of the layer without one. */
export function withItemAdded(
layers: readonly VectorLayer[],
layerId: string,
item: VectorItem,
drop: TreeDrop | null = null,
): VectorLayer[] {
const ownerId = requireOwner(layers, layerId, drop);
return mapLists(layers, (items, owner) => (owner.id === ownerId ? placedIn(items, [item], drop) : items));
}
/** The id of the list a drop goes into: the layer's own, or that of a group standing in it. */
function requireOwner(layers: readonly VectorLayer[], layerId: string, drop: TreeDrop | null): string {
const layer = requireLayer(layers, layerId);
const parentId = drop?.parentId ?? null;
if (parentId === null) return layerId;
const place = placeIn(layer, null, layer.items, parentId, 0);
if (!place || !isVectorGroup(place.item)) throw new VectorTreeError(`Group ${parentId} not found`);
return parentId;
}
/** Takes items out, a group with all that is in it. */
export function withItemsRemoved(layers: readonly VectorLayer[], itemIds: readonly string[]): VectorLayer[] {
const gone = new Set(itemIds);
const found = itemIds.filter((id) => placeOf(layers, id));
if (found.length === 0) throw new VectorTreeError('No such shape');
return mapLists(layers, (items) => items.filter((item) => !gone.has(item.id)));
}
/**
* Moves items to `drop` in the layer, or on top of the layer without one. They keep the order they
* are drawn in among themselves.
*/
export function withItemsMoved(
layers: readonly VectorLayer[],
itemIds: readonly string[],
layerId: string,
drop: TreeDrop | null = null,
): VectorLayer[] {
const moved = inDrawingOrder(layers, itemIds);
if (moved.length === 0) throw new VectorTreeError('No such shape');
const parentId = drop?.parentId ?? null;
for (const item of moved)
if (parentId !== null && (item.id === parentId || contains(item, parentId)))
throw new VectorTreeError('A group cannot be moved into itself');
const ownerId = requireOwner(layers, layerId, drop);
const gone = new Set(moved.map((item) => item.id));
// Not pruned while they are out: the group they go into may be the one they leave empty.
const put = (items: readonly VectorItem[], ownerHere: string): VectorItem[] => {
const kept: VectorItem[] = [];
for (const item of items) {
if (gone.has(item.id)) continue;
kept.push(isVectorGroup(item) ? { ...item, items: put(item.items, item.id) } : item);
}
return ownerHere === ownerId ? placedIn(kept, moved, drop) : kept;
};
const next = layers.map((layer) => ({ ...layer, items: put(layer.items, layer.id) }));
return mapLists(next, (items) => items);
}
function contains(item: VectorItem, itemId: string): boolean {
return isVectorGroup(item) && item.items.some((inner) => inner.id === itemId || contains(inner, itemId));
}
/** The named items in drawing order; one that stands inside another named one is left out. */
function inDrawingOrder(layers: readonly VectorLayer[], itemIds: readonly string[]): VectorItem[] {
const wanted = new Set(itemIds);
const walk = (items: readonly VectorItem[]): VectorItem[] =>
items.flatMap((item) => (wanted.has(item.id) ? [item] : isVectorGroup(item) ? walk(item.items) : []));
return layers.flatMap((layer) => walk(layer.items));
}
/**
* Puts items into a new group. It stands where the topmost of them stood, and they keep the order
* they are drawn in. Items of several layers cannot share a group.
*/
export function withItemsGrouped(
layers: readonly VectorLayer[],
itemIds: readonly string[],
group: { id: string; name: string },
): VectorLayer[] {
const members = inDrawingOrder(layers, itemIds);
const top = members[members.length - 1];
if (!top) throw new VectorTreeError('Nothing to group');
const places = members.map((item) => requirePlace(layers, item.id));
const place = places[places.length - 1]!;
if (places.some((p) => p.layer !== place.layer))
throw new VectorTreeError('Shapes of different layers cannot be grouped');
const gone = new Set(members.map((item) => item.id));
const ownerId = place.parent?.id ?? place.layer.id;
const made: VectorGroup = { id: group.id, name: group.name, items: members };
const put = (items: readonly VectorItem[], ownerHere: string): VectorItem[] => {
const kept: VectorItem[] = [];
for (const item of items) {
if (item.id === top.id && ownerHere === ownerId) kept.push(made);
else if (!gone.has(item.id))
kept.push(isVectorGroup(item) ? { ...item, items: put(item.items, item.id) } : item);
}
return kept;
};
const next = layers.map((layer) => ({ ...layer, items: put(layer.items, layer.id) }));
return mapLists(next, (items) => items);
}
/** Takes a group away and leaves what was in it in its place. */
export function withGroupDissolved(layers: readonly VectorLayer[], groupId: string): VectorLayer[] {
const place = requirePlace(layers, groupId);
if (!isVectorGroup(place.item)) throw new VectorTreeError(`Group ${groupId} not found`);
return mapLists(layers, (items) =>
items.flatMap((item) => (item.id === groupId && isVectorGroup(item) ? item.items : [item])),
);
}
/** One step, or all the way, among the items of its own list; null when it is there already. */
export function withItemOrdered(
layers: readonly VectorLayer[],
itemId: string,
move: ShapeOrderMove,
): VectorLayer[] | null {
const place = requirePlace(layers, itemId);
const to = shapeOrderTarget(place.siblings, itemId, move);
if (to < 0) return null;
const ownerId = place.parent?.id ?? place.layer.id;
return mapLists(layers, (items, owner) => {
if (owner.id !== ownerId) return items;
const next = items.filter((item) => item.id !== itemId);
next.splice(to, 0, place.item);
return next;
});
}
/** Whether the item can take that step – what switches an order button on. */
export function canOrderItem(layers: readonly VectorLayer[], itemId: string, move: ShapeOrderMove): boolean {
const place = placeOf(layers, itemId);
return place !== null && shapeOrderTarget(place.siblings, itemId, move) >= 0;
}
/** A new layer right over the layer `overId`, or on top of all without one. */
export function withLayerAdded(
layers: readonly VectorLayer[],
layer: VectorLayer,
overId = '',
): VectorLayer[] {
if (layers.some((l) => l.id === layer.id)) throw new VectorTreeError(`Layer ${layer.id} is there already`);
const at = layers.findIndex((l) => l.id === overId);
return at < 0 ? [...layers, layer] : [...layers.slice(0, at + 1), layer, ...layers.slice(at + 1)];
}
/** A drawing keeps one layer: there has to be somewhere to draw. */
export function withLayerRemoved(layers: readonly VectorLayer[], layerId: string): VectorLayer[] {
requireLayer(layers, layerId);
if (layers.length <= 1) throw new VectorTreeError('A drawing needs one layer');
return layers.filter((layer) => layer.id !== layerId);
}
export function withLayerMoved(layers: readonly VectorLayer[], from: number, to: number): VectorLayer[] {
const inside = (index: number) => Number.isInteger(index) && index >= 0 && index < layers.length;
if (!inside(from) || !inside(to)) throw new VectorTreeError('No such layer');
const next = [...layers];
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
export function withLayerChanged(
layers: readonly VectorLayer[],
layerId: string,
change: { name?: string; hidden?: boolean },
): VectorLayer[] {
requireLayer(layers, layerId);
return layers.map((layer) => {
if (layer.id !== layerId) return layer;
const next: VectorLayer = { ...layer, name: change.name ?? layer.name };
const hidden = change.hidden ?? layer.hidden === true;
if (hidden) next.hidden = true;
else delete next.hidden;
return next;
});
}
+4 -28
View File
@@ -13,6 +13,7 @@ import { fingerprint, type GeneratedPicture } from './generated';
import type { Project } from './Project'; import type { Project } from './Project';
import type { PropertyBag, Vec2 } from './Properties'; import type { PropertyBag, Vec2 } from './Properties';
import { paintText, textureFillText } from './vectorPaint'; import { paintText, textureFillText } from './vectorPaint';
import { allShapes, drawnShapes } from './vectorTree';
export const VECTOR_WIDTH_KEY = 'canvasWidth'; export const VECTOR_WIDTH_KEY = 'canvasWidth';
export const VECTOR_HEIGHT_KEY = 'canvasHeight'; export const VECTOR_HEIGHT_KEY = 'canvasHeight';
@@ -33,7 +34,7 @@ function canvasSide(value: unknown): number {
} }
export function findShape(asset: VectorArtAsset, shapeId: string): VectorShape | undefined { export function findShape(asset: VectorArtAsset, shapeId: string): VectorShape | undefined {
return asset.shapes.find((shape) => shape.id === shapeId); return allShapes(asset).find((shape) => shape.id === shapeId);
} }
/** /**
@@ -81,7 +82,8 @@ export function vectorArtRevision(
project?: Project, project?: Project,
): string { ): string {
const { width, height } = vectorCanvasSize(props); const { width, height } = vectorCanvasSize(props);
const shapes = asset.shapes.map((shape) => drawingText(shape, project)); // A hidden layer (#190) is not in the picture, so showing it again makes a new one.
const shapes = drawnShapes(asset).map((shape) => drawingText(shape, project));
return fingerprint(`${width}x${height}|${shapes.join('|')}`); return fingerprint(`${width}x${height}|${shapes.join('|')}`);
} }
@@ -135,29 +137,3 @@ export function cloneVectorPoint(point: VectorPoint): VectorPoint {
...(point.mode && point.mode !== 'corner' ? { mode: point.mode } : {}), ...(point.mode && point.mode !== 'corner' ? { mode: point.mode } : {}),
}; };
} }
/** Where a shape lands when it is moved in the drawing order (the user's four buttons of #129). */
export type ShapeOrderMove = 'forward' | 'front' | 'backward' | 'back';
/**
* The index `shapeId` moves to, or -1 when it is already there or not in the list. The list order is
* the drawing order and the last shape lies on top, so "forward" is one step towards the end.
*/
export function shapeOrderTarget(
shapes: readonly VectorShape[],
shapeId: string,
move: ShapeOrderMove,
): number {
const from = shapes.findIndex((shape) => shape.id === shapeId);
if (from < 0) return -1;
const last = shapes.length - 1;
const to =
move === 'forward'
? Math.min(from + 1, last)
: move === 'front'
? last
: move === 'backward'
? Math.max(from - 1, 0)
: 0;
return to === from ? -1 : to;
}
+77 -33
View File
@@ -38,6 +38,8 @@ import type {
VectorPaint, VectorPaint,
VectorPoint, VectorPoint,
VectorRing, VectorRing,
VectorItem,
VectorLayer,
VectorShape, VectorShape,
} from '../model/Asset'; } from '../model/Asset';
import { parseCellKey } from '../model/tilemap'; import { parseCellKey } from '../model/tilemap';
@@ -236,7 +238,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
} else if (type === 'tilemap') { } else if (type === 'tilemap') {
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) }); assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
} else if (type === 'vectorart') { } else if (type === 'vectorart') {
assets.push({ id, type, name, props, shapes: normalizeShapes(a.shapes) }); assets.push({ id, type, name, props, layers: normalizeVectorLayers(a) });
} else if (type === 'pixelart') { } else if (type === 'pixelart') {
assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) }); assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) });
} else if (type === 'palette') { } else if (type === 'palette') {
@@ -739,45 +741,87 @@ function normalizeLayers(raw: unknown): TilemapLayer[] {
} }
/** /**
* The shapes of a vector art asset (#129). A point whose coordinates are no numbers falls away, as * The layers of a vector art asset (#190). A drawing saved before there were layers holds a flat
* list of `shapes`; they become its one layer. A layer or an item whose id is taken is dropped, as a
* doubled tilemap layer is, and so is a group with nothing in it – a group is never empty. A
* drawing left without a layer gets one, since there has to be somewhere to draw.
*/
function normalizeVectorLayers(asset: Record<string, unknown>): VectorLayer[] {
const taken = new Set<string>();
const layers: VectorLayer[] = [];
for (const item of Array.isArray(asset.layers) ? asset.layers : []) {
const l = asRecord(item);
if (typeof l.id !== 'string' || !l.id || taken.has(l.id)) continue;
taken.add(l.id);
const layer: VectorLayer = {
id: l.id,
name: typeof l.name === 'string' && l.name ? l.name : 'Layer',
items: normalizeVectorItems(l.items, taken),
};
if (l.hidden === true) layer.hidden = true;
layers.push(layer);
}
if (layers.length === 0)
layers.push({ id: newId(), name: 'Layer 1', items: normalizeVectorItems(asset.shapes, taken) });
return layers;
}
/** Shapes and groups; whatever carries `items` is a group. */
function normalizeVectorItems(raw: unknown, taken: Set<string>): VectorItem[] {
const items: VectorItem[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const record = asRecord(item);
if (typeof record.id !== 'string' || !record.id || taken.has(record.id)) continue;
taken.add(record.id);
if (!Array.isArray(record.items)) {
items.push(normalizeShape(record, record.id));
continue;
}
const inner = normalizeVectorItems(record.items, taken);
if (inner.length > 0)
items.push({
id: record.id,
name: typeof record.name === 'string' ? record.name : 'Group',
items: inner,
});
}
return items;
}
/**
* One shape of a vector art asset (#129). A point whose coordinates are no numbers falls away, as
* a frame's outline corners do, and so does a handle that is not a pair of numbers – the point then * a frame's outline corners do, and so does a handle that is not a pair of numbers – the point then
* reads as a corner, which is what a shape drawn without handles says anyway. A shape left without * reads as a corner, which is what a shape drawn without handles says anyway. A shape left without
* any drawable ring is kept: it is an outline someone is still drawing, and the rasteriser passes * any drawable ring is kept: it is an outline someone is still drawing, and the rasteriser passes
* over it. A doubled shape id is dropped as a doubled layer is. * over it.
*/ */
function normalizeShapes(raw: unknown): VectorShape[] { function normalizeShape(s: Record<string, unknown>, id: string): VectorShape {
const shapes: VectorShape[] = []; const rings: VectorRing[] = [];
for (const item of Array.isArray(raw) ? raw : []) { for (const r of Array.isArray(s.rings) ? s.rings : []) {
const s = asRecord(item); const ring = asRecord(r);
if (typeof s.id !== 'string' || !s.id || shapes.some((o) => o.id === s.id)) continue; // Closed is what a shape usually is, and what every ring written by the editor says.
const rings: VectorRing[] = []; rings.push({ points: normalizePoints(ring.points), closed: ring.closed !== false });
for (const r of Array.isArray(s.rings) ? s.rings : []) {
const ring = asRecord(r);
// Closed is what a shape usually is, and what every ring written by the editor says.
rings.push({ points: normalizePoints(ring.points), closed: ring.closed !== false });
}
const shape: VectorShape = {
id: s.id,
name: typeof s.name === 'string' ? s.name : 'Shape',
rings,
fill: normalizeColor(s.fill, SHAPE_FILL_DEFAULT),
opacity: normalizeOpacity(s.opacity),
};
const paint = normalizePaint(s.paint);
if (paint) shape.paint = paint;
const stroke = asRecord(s.stroke);
const width = asFinite(stroke.width);
if (width !== null && width > 0) {
shape.stroke = { color: normalizeColor(stroke.color, 0), width };
const line = normalizePaint(stroke.paint);
if (line && line.type !== 'texture') shape.stroke.paint = line;
}
shapes.push(shape);
} }
return shapes; const shape: VectorShape = {
id,
name: typeof s.name === 'string' ? s.name : 'Shape',
rings,
fill: normalizeColor(s.fill, SHAPE_FILL_DEFAULT),
opacity: normalizeOpacity(s.opacity),
};
const paint = normalizePaint(s.paint);
if (paint) shape.paint = paint;
const stroke = asRecord(s.stroke);
const width = asFinite(stroke.width);
if (width !== null && width > 0) {
shape.stroke = { color: normalizeColor(stroke.color, 0), width };
const line = normalizePaint(stroke.paint);
if (line && line.type !== 'texture') shape.stroke.paint = line;
}
return shape;
} }
/** The points of a ring or of a curve (#247); the rules are those of `normalizeShapes`. */ /** The points of a ring or of a curve (#247); the rules are those of `normalizeShape`. */
function normalizePoints(raw: unknown): VectorPoint[] { function normalizePoints(raw: unknown): VectorPoint[] {
const points: VectorPoint[] = []; const points: VectorPoint[] = [];
for (const p of Array.isArray(raw) ? raw : []) { for (const p of Array.isArray(raw) ? raw : []) {
+43 -8
View File
@@ -1,10 +1,23 @@
import { findAsset } from '../../core/model/Project'; import { findAsset } from '../../core/model/Project';
import { currentGradientBar, currentShapeList } from '../inspector/shapeSection'; import { allShapes, treeRows } from '../../core/model/vectorTree';
import { currentGradientBar, currentLayerList, currentShapeList } from '../inspector/shapeSection';
import { activeLayerOf } from '../vectorart/activeLayer';
import { centreOf, snapButton, type HookContext } from './hookHelpers'; import { centreOf, snapButton, type HookContext } from './hookHelpers';
/** The Vector art tab (#129). */ /** The Vector art tab (#129). */
export function vectorArtHooks({ state, editor }: HookContext) { export function vectorArtHooks({ state, editor }: HookContext) {
const { vectorArtEditor } = editor; const { vectorArtEditor, inspector } = editor;
/** The two lists, scrolled into view first: since #190 the layers stand over the shapes. */
const shapeList = () => {
const list = currentShapeList();
if (list) inspector.reveal(list);
return list;
};
const layerList = () => {
const list = currentLayerList();
if (list) inspector.reveal(list);
return list;
};
return { return {
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */ /** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
vectorArt: { vectorArt: {
@@ -27,11 +40,11 @@ export function vectorArtHooks({ state, editor }: HookContext) {
enabled: vectorArtEditor.pointButton.enabled, enabled: vectorArtEditor.pointButton.enabled,
kind: vectorArtEditor.canvas.pickedKind, kind: vectorArtEditor.canvas.pickedKind,
}), }),
/** The shapes as the model holds them, so a check can read points and handles exactly. */ /** The shapes as the model holds them, in drawing order, so a check can read points and handles exactly. */
shapes: (assetId: string) => { shapes: (assetId: string) => {
const asset = findAsset(state.project, assetId); const asset = findAsset(state.project, assetId);
if (!asset || asset.type !== 'vectorart') return null; if (!asset || asset.type !== 'vectorart') return null;
return asset.shapes.map((shape) => ({ return allShapes(asset).map((shape) => ({
id: shape.id, id: shape.id,
name: shape.name, name: shape.name,
fill: shape.fill, fill: shape.fill,
@@ -50,6 +63,28 @@ export function vectorArtHooks({ state, editor }: HookContext) {
})), })),
})); }));
}, },
/**
* The layers (#190), lowest first: which one is worked on, and the tree of each as the list
* shows it – top row first, a group's rows one step deeper.
*/
layers: (assetId: string) => {
const asset = findAsset(state.project, assetId);
if (!asset || asset.type !== 'vectorart') return null;
const active = activeLayerOf(state, asset)?.id ?? '';
return asset.layers.map((layer) => ({
id: layer.id,
name: layer.name,
hidden: layer.hidden === true,
active: layer.id === active,
rows: treeRows(layer.items),
}));
},
/** The layer list in the inspector: the order it shows, top first, and where its parts sit. */
layerRows: () => currentLayerList()?.shownIds ?? null,
layerRowAt: (layerId: string) => layerList()?.rowGlobal(layerId) ?? null,
layerEyeAt: (layerId: string) => layerList()?.eyeGlobal(layerId) ?? null,
layerRemoveAt: (layerId: string) => layerList()?.removeGlobal(layerId) ?? null,
layerAddAt: () => layerList()?.addGlobal() ?? null,
selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''), selected: () => (state.selection?.kind === 'shape' ? state.selection.shapeId : ''),
selectedIds: () => state.selectedShapeIds, selectedIds: () => state.selectedShapeIds,
/** /**
@@ -57,12 +92,12 @@ export function vectorArtHooks({ state, editor }: HookContext) {
* row sits. A row carries a name that also stands in the tab, so a check cannot click it by text. * row sits. A row carries a name that also stands in the tab, so a check cannot click it by text.
*/ */
shapeRows: () => currentShapeList()?.shownIds ?? null, shapeRows: () => currentShapeList()?.shownIds ?? null,
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null, shapeRowAt: (shapeId: string) => shapeList()?.rowGlobal(shapeId) ?? null,
/** The cross on a shape's row. */ /** The cross on a shape's row. */
shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null, shapeRemoveAt: (shapeId: string) => shapeList()?.removeGlobal(shapeId) ?? null,
/** The duplicate mark on a shape's row, and where a double-click edits its name. */ /** The duplicate mark on a shape's row, and where a double-click edits its name. */
shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null, shapeDuplicateAt: (shapeId: string) => shapeList()?.duplicateGlobal(shapeId) ?? null,
shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null, shapeNameAt: (shapeId: string) => shapeList()?.nameGlobal(shapeId) ?? null,
/** The field a shape's name is being edited in, with what it holds; null while none is. */ /** The field a shape's name is being edited in, with what it holds; null while none is. */
shapeRenaming: () => { shapeRenaming: () => {
const field = currentShapeList()?.renameField; const field = currentShapeList()?.renameField;
+230 -59
View File
@@ -9,31 +9,42 @@
* rows, and only a rebuild puts the new list on screen. * rows, and only a rebuild puts the new list on screen.
*/ */
import { import {
AddVectorLayerCommand,
AddVectorShapeCommand, AddVectorShapeCommand,
CombineVectorShapesCommand, CombineVectorShapesCommand,
DuplicateVectorShapeCommand, DuplicateVectorShapeCommand,
MoveVectorShapeCommand, MoveVectorLayerCommand,
MoveVectorShapesCommand,
OrderVectorShapeCommand,
RemoveVectorLayerCommand,
RemoveVectorShapeCommand, RemoveVectorShapeCommand,
RenameVectorLayerCommand,
RenameVectorShapeCommand, RenameVectorShapeCommand,
SetVectorLayerHiddenCommand,
SetVectorShapeStyleCommand, SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands'; } from '../../core/commands/vectorArtCommands';
import type { import type {
VectorArtAsset, VectorArtAsset,
VectorGradient, VectorGradient,
VectorItem,
VectorPaint, VectorPaint,
VectorShape, VectorShape,
VectorStroke, VectorStroke,
} from '../../core/model/Asset'; } from '../../core/model/Asset';
import { type PropertyValue, textOf } from '../../core/model/Properties'; import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project'; import { findAssetOf, type Project } from '../../core/model/Project';
import { findShape, SHAPE_FILL_DEFAULT, shapeExtent, vectorCanvasSize } from '../../core/model/vectorart';
import { import {
findShape, allShapes,
SHAPE_FILL_DEFAULT, canOrderItem,
shapeExtent, findVectorItem,
isVectorGroup,
placeOf,
selectedItems,
type ShapeOrderMove, type ShapeOrderMove,
shapeOrderTarget, shapesIn,
vectorCanvasSize, treeRows,
} from '../../core/model/vectorart'; } from '../../core/model/vectorTree';
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean'; import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
import { import {
FILL_KINDS, FILL_KINDS,
@@ -50,10 +61,13 @@ import {
withStopChanged, withStopChanged,
} from '../../core/model/vectorPaint'; } from '../../core/model/vectorPaint';
import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Theme } from '../../ui'; import { Button, HStack, SortableList, Theme } from '../../ui';
import { removeShapes } from '../actions'; import { removeShapes } from '../actions';
import { FramePreview } from '../framePreview'; import { FramePreview } from '../framePreview';
import { listFold } from '../sectionFolds';
import { type EditorState, shapeSelection } from '../state'; import { type EditorState, shapeSelection } from '../state';
import { activeLayerOf, activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer';
import { groupBlocker, groupShapes, groupsToDissolve, ungroupShapes } from '../vectorart/grouping';
import { GradientBar } from '../vectorart/GradientBar'; import { GradientBar } from '../vectorart/GradientBar';
import { ShapeList } from '../vectorart/ShapeList'; import { ShapeList } from '../vectorart/ShapeList';
import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { createFieldRow, type FieldHandlers } from './fieldFactory';
@@ -174,6 +188,9 @@ const COMBINE_BUTTONS: { op: ShapeBoolean; label: string }[] = [
{ op: 'exclude', label: 'Exclude' }, { op: 'exclude', label: 'Exclude' },
]; ];
/** The layer the selected shapes stand on (#190); picking another one moves them there. */
const LAYER_KEY = 'shapeLayer';
const FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT }; const FILL_DEF: PropertyDef = { key: 'fill', label: 'Fill', kind: 'color', default: SHAPE_FILL_DEFAULT };
const OPACITY_DEF: PropertyDef = { const OPACITY_DEF: PropertyDef = {
key: 'opacity', key: 'opacity',
@@ -202,21 +219,33 @@ const STROKE_WIDTH_DEF: PropertyDef = {
decimals: 1, decimals: 1,
}; };
/** The list as it was last built, so a check can address a row that carries a name, not a widget. */ /** The lists as they were last built, so a check can address a row that carries a name, not a widget. */
let built: ShapeList | null = null; let built: ShapeList | null = null;
let builtLayers: SortableList | null = null;
/** Null while no vector art shapes are on screen. */ /** Null while no vector art shapes are on screen. */
export function currentShapeList(): ShapeList | null { export function currentShapeList(): ShapeList | null {
return built; return built;
} }
/** What the shapes of an asset make of the rows: their order, their names, and who is drawn. */ /** Null while no vector art is in the inspector. */
export function shapeListSignature(asset: VectorArtAsset): string { export function currentLayerList(): SortableList | null {
return asset.shapes.map((shape) => `${shape.id}:${shape.name}`).join(','); return builtLayers;
} }
/** /**
* Structure-defining facts of a shape selection: the list above it, whether the shape has an * What a drawing makes of the rows: its layers, which of them is worked on, and the tree of that
* one – order, names and who stands in which group. `shapeId` is the selected shape, if one is.
*/
export function shapeListSignature(asset: VectorArtAsset, shapeId = ''): string {
const active = activeVectorLayer(asset, shapeId);
const layers = asset.layers.map((layer) => `${layer.id}:${layer.name}:${layer.hidden ? 'h' : 's'}`);
const tree = treeRows(active?.items ?? []).map((row) => `${row.id}:${row.name}:${row.depth}`);
return `${layers.join(',')}@${active?.id ?? ''}[${tree.join(',')}]`;
}
/**
* Structure-defining facts of a shape selection: the lists above it, whether the shape has an
* outline – its colour and width are rows of their own and appear with it – and whether the * outline – its colour and width are rows of their own and appear with it – and whether the
* selected shapes can be combined, which switches the four buttons and the note under them. * selected shapes can be combined, which switches the four buttons and the note under them.
*/ */
@@ -231,25 +260,26 @@ export function shapeSignature(
if (!asset || !shape) return 'missing'; if (!asset || !shape) return 'missing';
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? ''; const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`; const paints = `${fillKind(shape)}/${shape.stroke ? strokeKind(shape) : 'none'}`;
return `${shapeListSignature(asset)}|${paints}|${shapeIds.join(',')}|${blocked}`; return `${shapeListSignature(asset, shapeId)}|${paints}|${shapeIds.join(',')}|${blocked}`;
} }
/** The selected shapes in drawing order, the lowest first – the order they are combined in. */ /** The selected shapes in drawing order, the lowest first – the order they are combined in. */
function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): VectorShape[] { function selectedShapes(asset: VectorArtAsset, shapeIds: readonly string[]): VectorShape[] {
return asset.shapes.filter((shape) => shapeIds.includes(shape.id)); return allShapes(asset).filter((shape) => shapeIds.includes(shape.id));
} }
/** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */ /** The shapes of the asset, with nothing chosen: what the asset's own section shows underneath. */
export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void { export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset): void {
addShapeList(host, asset, []); addLayerList(host, asset, '');
addShapeList(host, asset, '', []);
addNote(host, 'The top row is drawn over the ones below it.', 'small'); addNote(host, 'The top row is drawn over the ones below it.', 'small');
addGap(host); addGap(host);
host.body.add(addShapeButton(host, asset.id)); host.body.add(addShapeButton(host, asset.id));
} }
/** /**
* Name, place in the drawing order and style of the primary shape, under the list it is picked * Name, place in the drawing order and style of the primary shape, under the lists it is picked
* from, and the buttons that combine all selected shapes into one (#187). * from, and the buttons that combine all selected shapes into one (#187) or group them (#190).
*/ */
export function buildShapeSection( export function buildShapeSection(
host: InspectorHost, host: InspectorHost,
@@ -261,28 +291,34 @@ export function buildShapeSection(
const shape = asset && findShape(asset, shapeId); const shape = asset && findShape(asset, shapeId);
if (!asset || !shape) return; if (!asset || !shape) return;
addShapeList(host, asset, shapeIds); addLayerList(host, asset, shapeId);
addNote(host, 'Drag a row to draw it earlier or later.', 'small'); addShapeList(host, asset, shapeId, shapeIds);
addNote(host, 'Drag a row to draw it earlier or later, or into a group.', 'small');
// A group picked as a whole is what the buttons move; otherwise it is the primary shape.
const items = selectedItems(asset.layers, shapeIds);
const ordered = items.length === 1 ? items[0]! : shapeId;
// Two rows of two: four of them side by side cut "Backward" down to "Backw…" at the panel's width. // Two rows of two: four of them side by side cut "Backward" down to "Backw…" at the panel's width.
for (let start = 0; start < ORDER_BUTTONS.length; start += 2) { for (let start = 0; start < ORDER_BUTTONS.length; start += 2) {
const order = new HStack({ gap: Theme.gap }); const order = new HStack({ gap: Theme.gap });
for (const { move, label } of ORDER_BUTTONS.slice(start, start + 2)) { for (const { move, label } of ORDER_BUTTONS.slice(start, start + 2)) {
const button = new Button(label, { const button = new Button(label, {
variant: 'ghost', variant: 'ghost',
onClick: () => moveShape(host, assetId, shapeId, move), onClick: () => void host.state.execute(new OrderVectorShapeCommand(assetId, ordered, move)),
}); });
// A shape already at the front has nowhere to go forwards. // A shape already at the front of its list has nowhere to go forwards.
button.setEnabled(shapeOrderTarget(asset.shapes, shapeId, move) >= 0); button.setEnabled(canOrderItem(asset.layers, ordered, move));
order.add(button, 1); order.add(button, 1);
} }
host.body.add(order); host.body.add(order);
} }
addGroupButtons(host, asset, shapeId, shapeIds);
addCombineButtons(host, asset, shapeIds); addCombineButtons(host, asset, shapeIds);
addTitle(host, 'VECTOR SHAPE'); addTitle(host, 'VECTOR SHAPE');
addShapeName(host, assetId, shape); addShapeName(host, assetId, shape);
addLayerRow(host, asset, shapeId, shapeIds);
fillRows(host, assetId, shape); fillRows(host, assetId, shape);
styleRow( styleRow(
host, host,
@@ -330,6 +366,67 @@ export function buildShapeSection(
host.body.add(addShapeButton(host, assetId)); host.body.add(addShapeButton(host, assetId));
} }
/**
* Group and Ungroup (#190). Group puts what is selected into a new group, a group picked as a whole
* going in as one. Ungroup takes the picked groups away, or the group the primary shape stands in.
*/
function addGroupButtons(
host: InspectorHost,
asset: VectorArtAsset,
shapeId: string,
shapeIds: readonly string[],
): void {
const row = new HStack({ gap: Theme.gap });
const group = new Button('Group', { variant: 'ghost', onClick: () => groupShapes(host.state, asset.id) });
const ungroup = new Button('Ungroup', {
variant: 'ghost',
onClick: () => ungroupShapes(host.state, asset.id),
});
group.setEnabled(groupBlocker(asset, shapeIds) === null);
ungroup.setEnabled(groupsToDissolve(asset, shapeId, shapeIds).length > 0);
row.add(group, 1);
row.add(ungroup, 1);
host.body.add(row);
}
/** The layer the selection stands on, as a row; with one layer there is nothing to choose. */
function addLayerRow(
host: InspectorHost,
asset: VectorArtAsset,
shapeId: string,
shapeIds: readonly string[],
): void {
if (asset.layers.length < 2) return;
const def: PropertyDef = {
key: LAYER_KEY,
label: 'Layer',
kind: 'enum',
default: '',
options: [...asset.layers].reverse().map((layer) => ({ value: layer.id, label: layer.name })),
};
const current = placeOf(asset.layers, shapeId)?.layer.id ?? '';
const row = createFieldRow(host, def, current, {
onInput: () => {},
onChange: (input) => moveShapesToLayer(host.state, asset.id, shapeIds, textOf(input)),
});
host.fields.set(LAYER_KEY, row);
host.body.add(row.row);
}
/** Moves the selected shapes, groups as wholes, on top of another layer. Exported as `setPaintKind` is. */
export function moveShapesToLayer(
state: EditorState,
assetId: string,
shapeIds: readonly string[],
layerId: string,
): void {
const asset = findAssetOf(state.project, assetId, 'vectorart');
if (!asset) return;
const items = selectedItems(asset.layers, shapeIds);
if (items.every((id) => placeOf(asset.layers, id)?.layer.id === layerId)) return;
state.execute(new MoveVectorShapesCommand(assetId, items, layerId));
}
/** /**
* Union, subtract, intersect, exclude – two rows of two, like the order buttons. They are there with * Union, subtract, intersect, exclude – two rows of two, like the order buttons. They are there with
* one shape selected too, switched off, so the note under them can say how to get a second one. * one shape selected too, switched off, so the note under them can say how to get a second one.
@@ -361,6 +458,8 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
const shape = currentShape(host, assetId, shapeId); const shape = currentShape(host, assetId, shapeId);
if (!shape) return false; if (!shape) return false;
host.fields.get('name')?.setValue(shape.name); host.fields.get('name')?.setValue(shape.name);
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
if (asset) host.fields.get(LAYER_KEY)?.setValue(placeOf(asset.layers, shapeId)?.layer.id ?? '');
host.fields.get('fill')?.setValue(shape.fill); host.fields.get('fill')?.setValue(shape.fill);
host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape)); host.fields.get(FILL_TYPE_DEF.key)?.setValue(fillKind(shape));
host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape)); host.fields.get(LINE_TYPE_DEF.key)?.setValue(strokeKind(shape));
@@ -374,49 +473,130 @@ export function refreshShapeSection(host: InspectorHost, assetId: string, shapeI
return true; return true;
} }
function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: readonly string[]): void { /** The layers of the drawing, the top one drawn last; the marked one is the one being worked on. */
if (asset.shapes.length === 0) { function addLayerList(host: InspectorHost, asset: VectorArtAsset, shapeId: string): void {
const { state } = host;
const active = activeVectorLayer(asset, shapeId);
const list = new SortableList({
title: 'Layers',
...listFold('inspector', 'Layers'),
reversed: true,
keepOne: true,
onPick: (layerId) => pickVectorLayer(state, asset.id, layerId),
onMove: (from, to) => void state.execute(new MoveVectorLayerCommand(asset.id, from, to)),
onToggleHidden: (layerId) => {
const layer = asset.layers.find((l) => l.id === layerId);
if (layer) state.execute(new SetVectorLayerHiddenCommand(asset.id, layerId, !layer.hidden));
},
onAdd: () => {
const command = new AddVectorLayerCommand(asset.id, active?.id ?? '');
if (state.execute(command)) pickVectorLayer(state, asset.id, command.layerId);
},
onRemove: (layerId) => void state.execute(new RemoveVectorLayerCommand(asset.id, layerId)),
onRename: (layerId, name) => void state.execute(new RenameVectorLayerCommand(asset.id, layerId, name)),
});
list.setItems(
asset.layers.map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden === true })),
active ? [active.id] : [],
);
builtLayers = list;
host.body.add(list);
}
/** The tree of the active layer; a group whose shapes are all selected is marked with them. */
function addShapeList(
host: InspectorHost,
asset: VectorArtAsset,
shapeId: string,
selectedIds: readonly string[],
): void {
const layer = activeVectorLayer(asset, shapeId);
const rows = treeRows(layer?.items ?? []);
if (!layer || rows.length === 0) {
built = null; built = null;
addTitle(host, 'SHAPES'); addTitle(host, 'SHAPES');
addNote(host, 'Nothing drawn yet – take the Pen in the Vector art tab.'); addNote(
host,
allShapes(asset).length === 0
? 'Nothing drawn yet – take the Pen in the Vector art tab.'
: 'Nothing on this layer yet – the Pen draws onto it.',
);
return; return;
} }
const list = new ShapeList( const { state } = host;
host.state, const list = new ShapeList(state, asset.id, rows, markedRows(asset, selectedIds), {
asset.id, onPick: (itemId, toggle) => pickShapeRow(state, asset.id, itemId, toggle),
asset.shapes.map((shape) => shape.id), onDrop: (itemId, drop) =>
selectedIds, void state.execute(new MoveVectorShapesCommand(asset.id, [itemId], layer.id, drop)),
{ onDuplicate: (itemId) => {
onPick: (shapeId, toggle) => const command = new DuplicateVectorShapeCommand(asset.id, itemId);
toggle if (state.execute(command)) pickShapeRow(state, asset.id, command.shapeId, false);
? host.state.toggleShape(asset.id, shapeId)
: host.state.select(shapeSelection(asset.id, [shapeId])),
onReorder: (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
onDuplicate: (shapeId) => {
const command = new DuplicateVectorShapeCommand(asset.id, shapeId);
if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId]));
},
onRemove: (shapeId) => removeShape(host, asset.id, shapeId),
onRename: (shapeId, name) =>
void host.state.execute(new RenameVectorShapeCommand(asset.id, shapeId, name)),
}, },
); onRemove: (itemId) => removeShape(host, asset.id, itemId),
onRename: (itemId, name) => void state.execute(new RenameVectorShapeCommand(asset.id, itemId, name)),
});
built = list; built = list;
host.body.add(list); host.body.add(list);
} }
/** The cross on a row: the shape goes, and with it out of the selection – the rest stays selected. */ /** The selected shapes, and every group all of whose shapes are selected. */
function removeShape(host: InspectorHost, assetId: string, shapeId: string): void { function markedRows(asset: VectorArtAsset, shapeIds: readonly string[]): string[] {
const rest = host.state.selectedShapeIds.filter((id) => id !== shapeId); const picked = new Set(shapeIds);
const groups: string[] = [];
const walk = (items: readonly VectorItem[]): void => {
for (const item of items) {
if (!isVectorGroup(item)) continue;
if (shapesIn(item.items).every((shape) => picked.has(shape.id))) groups.push(item.id);
walk(item.items);
}
};
for (const layer of asset.layers) walk(layer.items);
return [...shapeIds, ...groups];
}
/** The shapes a row stands for: itself, or all that is in the group. */
function shapeIdsOf(state: EditorState, assetId: string, itemId: string): string[] {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const item = asset && findVectorItem(asset, itemId);
if (!item) return [];
return isVectorGroup(item) ? shapesIn(item.items).map((shape) => shape.id) : [item.id];
}
/**
* A row pressed. A group's row selects all that is in it. With Ctrl or Shift the row's shapes join
* the selection, or leave it when all of them were in it already. Exported as `setPaintKind` is.
*/
export function pickShapeRow(state: EditorState, assetId: string, itemId: string, toggle: boolean): void {
const ids = shapeIdsOf(state, assetId, itemId);
if (ids.length === 0) return;
if (!toggle) {
state.select(shapeSelection(assetId, ids));
return;
}
const held =
state.selection?.kind === 'shape' && state.selection.assetId === assetId ? state.selectedShapeIds : [];
const all = ids.every((id) => held.includes(id));
const next = all
? held.filter((id) => !ids.includes(id))
: [...held, ...ids.filter((id) => !held.includes(id))];
state.select(shapeSelection(assetId, next) ?? { kind: 'asset', assetId });
}
/** The cross on a row: the item goes, and with it out of the selection – the rest stays selected. */
function removeShape(host: InspectorHost, assetId: string, itemId: string): void {
const gone = shapeIdsOf(host.state, assetId, itemId);
const rest = host.state.selectedShapeIds.filter((id) => !gone.includes(id));
const wasSelected = rest.length !== host.state.selectedShapeIds.length; const wasSelected = rest.length !== host.state.selectedShapeIds.length;
if (!host.state.execute(new RemoveVectorShapeCommand(assetId, shapeId)) || !wasSelected) return; if (!host.state.execute(new RemoveVectorShapeCommand(assetId, itemId)) || !wasSelected) return;
host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId }); host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId });
} }
function addShapeButton(host: InspectorHost, assetId: string): Button { function addShapeButton(host: InspectorHost, assetId: string): Button {
return new Button('Add shape', { return new Button('Add shape', {
onClick: () => { onClick: () => {
const command = new AddVectorShapeCommand(assetId); const asset = findAssetOf(host.state.project, assetId, 'vectorart');
const layer = asset && activeLayerOf(host.state, asset);
const command = new AddVectorShapeCommand(assetId, [], undefined, layer?.id);
if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId])); if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId]));
}, },
}); });
@@ -441,15 +621,6 @@ function addShapeName(host: InspectorHost, assetId: string, shape: VectorShape):
host.body.add(row.row); host.body.add(row.row);
} }
function moveShape(host: InspectorHost, assetId: string, shapeId: string, move: ShapeOrderMove): void {
const asset = findAssetOf(host.state.project, assetId, 'vectorart');
if (!asset) return;
const from = asset.shapes.findIndex((shape) => shape.id === shapeId);
const to = shapeOrderTarget(asset.shapes, shapeId, move);
if (from < 0 || to < 0) return;
host.state.execute(new MoveVectorShapeCommand(assetId, from, to));
}
/** An outline switched on gets a black line; switched off it is taken away, not made invisible. */ /** An outline switched on gets a black line; switched off it is taken away, not made invisible. */
function outlineRow(host: InspectorHost, assetId: string, shapeId: string, shape: VectorShape): void { function outlineRow(host: InspectorHost, assetId: string, shapeId: string, shape: VectorShape): void {
const row = createFieldRow(host, OUTLINE_DEF, Boolean(shape.stroke), { const row = createFieldRow(host, OUTLINE_DEF, Boolean(shape.stroke), {
+3
View File
@@ -1,6 +1,7 @@
import { ui } from '../ui'; import { ui } from '../ui';
import { createContainer, removeSelection } from './actions'; import { createContainer, removeSelection } from './actions';
import type { EditorState } from './state'; import type { EditorState } from './state';
import { groupShapes, ungroupShapes } from './vectorart/grouping';
export interface ShortcutActions { export interface ShortcutActions {
play(): void; play(): void;
@@ -52,6 +53,8 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
if (mod && key === 'g') { if (mod && key === 'g') {
const s = state.selection; const s = state.selection;
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
// On the shapes of a vector drawing it groups them, and with Shift takes the group away (#190).
else if (s?.kind === 'shape') (event.shiftKey ? ungroupShapes : groupShapes)(state, s.assetId);
return true; return true;
} }
if (event.key === 'Delete' || event.key === 'Backspace') if (event.key === 'Delete' || event.key === 'Backspace')
+3 -1
View File
@@ -7,6 +7,7 @@ import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset'; import type { Asset, SceneAsset } from '../core/model/Asset';
import { findAnnotation, type Graph } from '../core/graph'; import { findAnnotation, type Graph } from '../core/graph';
import { findObject } from '../core/model/hierarchy'; import { findObject } from '../core/model/hierarchy';
import { allShapes } from '../core/model/vectorTree';
import { resolvedHolder } from '../core/model/instance'; import { resolvedHolder } from '../core/model/instance';
import { import {
createEmptyProject, createEmptyProject,
@@ -719,7 +720,8 @@ export class EditorState {
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo). // As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
if (!asset) this.select(null); if (!asset) this.select(null);
else { else {
const alive = s.shapeIds.filter((id) => asset.shapes.some((shape) => shape.id === id)); const shapes = allShapes(asset);
const alive = s.shapeIds.filter((id) => shapes.some((shape) => shape.id === id));
if (alive.length !== s.shapeIds.length) if (alive.length !== s.shapeIds.length)
this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId }); this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId });
} }
+10 -2
View File
@@ -6,7 +6,7 @@ import { imageToLocal, type SheetView, zoomAbout } from './sheetView';
/** /**
* The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art * The shell of a tab that shows one picture-like asset under a `SheetView` – the Texture, Pixel art
* and Vector art tabs. It owns the ground, the clip, the hint and the view: a middle drag or a * and Vector art tabs. It owns the ground, the clip, the hint and the view: a middle drag or a
* Shift drag pans, the wheel zooms, Fit fits, and each document keeps its view. What a left press * Shift drag pans (unless the tab takes the Shift press itself), the wheel zooms, Fit fits, and each document keeps its view. What a left press
* does is the tab's; it only gets one while `pressTarget` has something to press on. * does is the tab's; it only gets one while `pressTarget` has something to press on.
*/ */
export abstract class SheetCanvas<A> extends Widget { export abstract class SheetCanvas<A> extends Widget {
@@ -44,6 +44,13 @@ export abstract class SheetCanvas<A> extends Widget {
/** The pointer moved while no pan runs. */ /** The pointer moved while no pan runs. */
protected abstract move(local: Vec2): void; protected abstract move(local: Vec2): void;
protected abstract release(): void; protected abstract release(): void;
/**
* Whether a Shift press here is the tab's own rather than a pan: the vector art tab takes it on a
* shape, where it adds to the selection, as the scene view does on an object.
*/
protected shiftPresses(_target: A, _local: Vec2): boolean {
return false;
}
/** A right-click; a tab with a menu for what lies under the pointer opens it here. */ /** A right-click; a tab with a menu for what lies under the pointer opens it here. */
protected context(_target: A, _e: FederatedPointerEvent, _local: Vec2): void {} protected context(_target: A, _e: FederatedPointerEvent, _local: Vec2): void {}
@@ -101,7 +108,8 @@ export abstract class SheetCanvas<A> extends Widget {
const target = this.pressTarget(); const target = this.pressTarget();
if (!target) return; if (!target) return;
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
if (e.button === 1 || (e.button === 0 && e.shiftKey)) { const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
if (e.button === 1 || pans) {
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY }; this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
e.stopPropagation(); e.stopPropagation();
return; return;
+44 -21
View File
@@ -1,10 +1,12 @@
/** /**
* The shapes of a vector art asset as a list with a preview each (#129, stage 4). It stands in the * The shapes of one layer of a vector art asset as a list with a preview each (#129, stage 4). It
* inspector, over the rows that colour the selected shape – a panel of its own is #191. * stands in the inspector, over the rows that colour the selected shape – a panel of its own is #191.
* *
* Each row draws the shape itself, with the same commands the rasteriser uses, placed on the canvas * Each row draws the shape itself, with the same commands the rasteriser uses, placed on the canvas
* it sits on: two shapes of the same colour are told apart by where they are, which a name rarely * it sits on: two shapes of the same colour are told apart by where they are, which a name rarely
* says. The list is the drawing order, top row drawn last, and a row is dragged to reorder it. * says. The list is the drawing order, top row drawn last. A group (#190) stands over what is in it,
* its rows one step in, and shows all of it in its picture. A row is dragged to another place; the
* marker starts where the row would, and dragging right or left picks the group at the end of one.
* *
* The rows are fixed when the list is built: the inspector section rebuilds whenever a shape is * The rows are fixed when the list is built: the inspector section rebuilds whenever a shape is
* added, removed or renamed. A shape merely edited repaints its preview, which is why the shapes * added, removed or renamed. A shape merely edited repaints its preview, which is why the shapes
@@ -15,6 +17,15 @@ import type { VectorArtAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project'; import { findAssetOf } from '../../core/model/Project';
import { vectorCanvasSize } from '../../core/model/vectorart'; import { vectorCanvasSize } from '../../core/model/vectorart';
import { shapeDrawing } from '../../core/model/vectorPath'; import { shapeDrawing } from '../../core/model/vectorPath';
import {
findVectorItem,
isVectorGroup,
shapesIn,
type TreeDrop,
treeDrop,
treeDropDepth,
type TreeRow,
} from '../../core/model/vectorTree';
import { textureLibrary } from '../../viewport/TextureLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary';
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture'; import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
import { SortableList, type ThumbClip, type Widget } from '../../ui'; import { SortableList, type ThumbClip, type Widget } from '../../ui';
@@ -27,39 +38,49 @@ export class ShapeList extends SortableList {
private readonly stopPictures: () => void; private readonly stopPictures: () => void;
/** /**
* `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with * `tree` is the layer's tree as rows, top first, and `selectedIds` the rows marked. `onPick` is told
* Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onDuplicate` and * the row pressed and whether it was pressed with Ctrl or Shift, which adds the shape to the
* selection or takes it out (#187). `onDrop` is told where a dragged row landed. `onDuplicate` and
* `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no * `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no
* plus: a shape is drawn with the Pen, and "Add shape" stays below. * plus: a shape is drawn with the Pen, and "Add shape" stays below.
*/ */
constructor( constructor(
private readonly state: EditorState, private readonly state: EditorState,
private readonly assetId: string, private readonly assetId: string,
shapeIds: readonly string[], tree: readonly TreeRow[],
selectedIds: readonly string[], selectedIds: readonly string[],
rows: { rows: {
onPick: (shapeId: string, toggle: boolean) => void; onPick: (itemId: string, toggle: boolean) => void;
onReorder: (from: number, to: number) => void; onDrop: (itemId: string, drop: TreeDrop) => void;
onDuplicate: (shapeId: string) => void; onDuplicate: (itemId: string) => void;
onRemove: (shapeId: string) => void; onRemove: (itemId: string) => void;
onRename: (shapeId: string, name: string) => void; onRename: (itemId: string, name: string) => void;
}, },
) { ) {
super({ super({
title: 'Shapes', title: 'Shapes',
...listFold('inspector', 'Shapes'), ...listFold('inspector', 'Shapes'),
reversed: true,
multiSelect: true, multiSelect: true,
onPick: rows.onPick, onPick: rows.onPick,
onMove: rows.onReorder, onMove: () => {},
onDrop: (id, gap, depth) => {
const drop = treeDrop(tree, id, gap, depth);
if (drop) rows.onDrop(id, drop);
},
dropDepth: (id, gap, depth) => treeDropDepth(tree, id, gap, depth),
onDuplicate: rows.onDuplicate, onDuplicate: rows.onDuplicate,
onRemove: rows.onRemove, onRemove: rows.onRemove,
onRename: rows.onRename, onRename: rows.onRename,
thumb: (id, art, size) => this.paintSwatch(id, art, size), thumb: (id, art, size) => this.paintSwatch(id, art, size),
}); });
const asset = this.asset();
this.setItems( this.setItems(
shapeIds.map((id) => ({ id, label: asset?.shapes.find((s) => s.id === id)?.name ?? '' })), // Nothing else tells a group's row from a shape's at the same depth.
tree.map((row) => ({
id: row.id,
label: row.name,
depth: row.depth,
...(row.group ? { hint: 'group' } : {}),
})),
selectedIds, selectedIds,
); );
// A point dragged in the canvas changes no row, only what a row shows. // A point dragged in the canvas changes no row, only what a row shows.
@@ -87,12 +108,12 @@ export class ShapeList extends SortableList {
/** /**
* The shape alone, on the canvas it sits on: the whole canvas is scaled into the swatch, so a * The shape alone, on the canvas it sits on: the whole canvas is scaled into the swatch, so a
* shape in the corner is drawn in the corner. What lies outside the canvas is cut off here as it * shape in the corner is drawn in the corner. What lies outside the canvas is cut off here as it
* is in the picture. * is in the picture. A group shows all its shapes.
*/ */
private paintSwatch(shapeId: string, art: Graphics, size: number): ThumbClip | void { private paintSwatch(itemId: string, art: Graphics, size: number): ThumbClip | void {
const asset = this.asset(); const asset = this.asset();
const shape = asset?.shapes.find((s) => s.id === shapeId); const item = asset && findVectorItem(asset, itemId);
if (!asset || !shape) return; if (!asset || !item) return;
const { width, height } = vectorCanvasSize(asset.props); const { width, height } = vectorCanvasSize(asset.props);
const scale = Math.min(size / width, size / height); const scale = Math.min(size / width, size / height);
const clip = { const clip = {
@@ -104,8 +125,10 @@ export class ShapeList extends SortableList {
art.position.x += clip.x; art.position.x += clip.x;
art.position.y += clip.y; art.position.y += clip.y;
art.scale.set(scale); art.scale.set(scale);
const drawing = shapeDrawing(shape, this.state.project); for (const shape of isVectorGroup(item) ? shapesIn(item.items) : [item]) {
if (drawing) paintDrawing(art, drawing, this.paints); const drawing = shapeDrawing(shape, this.state.project);
if (drawing) paintDrawing(art, drawing, this.paints);
}
return clip; return clip;
} }
} }
+103 -46
View File
@@ -11,6 +11,7 @@
* the Texture tab edits a collision outline. * the Texture tab edits a collision outline.
*/ */
import { type FederatedPointerEvent, Graphics } from 'pixi.js'; import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { import {
AddVectorShapeCommand, AddVectorShapeCommand,
paintsOf, paintsOf,
@@ -29,6 +30,7 @@ import { findAsset } from '../../core/model/Project';
import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart'; import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart';
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint'; import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
import { vectorArtDrawings } from '../../core/model/vectorPath'; import { vectorArtDrawings } from '../../core/model/vectorPath';
import { allShapes, drawnShapes, outermostGroup, shapesIn } from '../../core/model/vectorTree';
import { textureLibrary } from '../../viewport/TextureLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary';
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture'; import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
import { cancelFrameWork, onFrame, pressCount, Theme, type Widget } from '../../ui'; import { cancelFrameWork, onFrame, pressCount, Theme, type Widget } from '../../ui';
@@ -55,6 +57,7 @@ import { type EditorState, shapeSelection } from '../state';
import { drawChecker } from '../texture/checker'; import { drawChecker } from '../texture/checker';
import { SheetCanvas } from '../texture/SheetCanvas'; import { SheetCanvas } from '../texture/SheetCanvas';
import { fitView, imageToLocal, localToImage } from '../texture/sheetView'; import { fitView, imageToLocal, localToImage } from '../texture/sheetView';
import { activeLayerOf } from './activeLayer';
/** /**
* Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint * Which gesture a press begins: the arrow edits what is there, the pen adds to it, and the paint
@@ -68,12 +71,20 @@ export type PaintTarget = 'fill' | 'stroke';
/** The grid points snap to while snapping is on, in canvas pixels. */ /** The grid points snap to while snapping is on, in canvas pixels. */
const SNAP_STEP = 8; const SNAP_STEP = 8;
/** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */ /** One shape as a drag moves it. `before` is what it read when the drag began – the undo step's other side. */
interface ShapeDrag { interface MovedShape {
kind: 'point' | 'handle' | 'shape' | 'pen';
shapeId: string; shapeId: string;
before: VectorRing[]; before: VectorRing[];
rings: VectorRing[]; rings: VectorRing[];
/** Its paints as they were – they travel with a whole shape. */
paints: ReturnType<typeof paintsOf>;
}
/** A gesture in progress, on the shape it began on. */
interface ShapeDrag extends MovedShape {
kind: 'point' | 'handle' | 'shape' | 'pen';
/** The other selected shapes, which a whole shape dragged takes along (#190). */
others: MovedShape[];
/** Which ring and which of its points the gesture is about; -1 for a whole shape. */ /** Which ring and which of its points the gesture is about; -1 for a whole shape. */
ring: number; ring: number;
index: number; index: number;
@@ -83,9 +94,8 @@ interface ShapeDrag {
at: Vec2; at: Vec2;
moved: boolean; moved: boolean;
label: string; label: string;
/** How far a whole shape has been dragged, and its paints as they were – they travel with it. */ /** How far a whole shape has been dragged. */
delta: Vec2; delta: Vec2;
paints: ReturnType<typeof paintsOf>;
} }
/** A paint handle being dragged; `before` is the paint as it was, the undo step's other side. */ /** A paint handle being dragged; `before` is the paint as it was, the undo step's other side. */
@@ -329,6 +339,12 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
} }
} }
/** Shift on a shape adds it to the selection; on the ground beside the shapes it still pans. */
protected override shiftPresses(asset: VectorArtAsset, local: Vec2): boolean {
if (this.tool !== 'select') return false;
return this.shapeAt(asset, localToImage(this.view, local.x, local.y)) !== undefined;
}
protected press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void { protected press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void {
const at = localToImage(this.view, local.x, local.y); const at = localToImage(this.view, local.x, local.y);
const tolerance = POINT_SIZE / this.view.zoom; const tolerance = POINT_SIZE / this.view.zoom;
@@ -347,7 +363,15 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
const point = this.snapped(at); const point = this.snapped(at);
const open = this.openRing(); const open = this.openRing();
if (!open) { if (!open) {
const command = new AddVectorShapeCommand(asset.id, [{ points: [point], closed: false }]); const layer = activeLayerOf(this.state, asset);
// A line drawn onto a hidden layer could not be seen; the hint says to show the layer first.
if (!layer || layer.hidden) return;
const command = new AddVectorShapeCommand(
asset.id,
[{ points: [point], closed: false }],
undefined,
layer.id,
);
if (!this.state.execute(command)) return; if (!this.state.execute(command)) return;
this.state.select(shapeSelection(asset.id, [command.shapeId])); this.state.select(shapeSelection(asset.id, [command.shapeId]));
this.pen = { shapeId: command.shapeId, ring: 0 }; this.pen = { shapeId: command.shapeId, ring: 0 };
@@ -393,6 +417,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
label: 'Draw shape', label: 'Draw shape',
delta: { x: 0, y: 0 }, delta: { x: 0, y: 0 },
paints: {}, paints: {},
others: [],
}; };
this.preview(); this.preview();
} }
@@ -402,8 +427,10 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
* drawn later: a press on a point picks it and drags it, Alt makes it round or square again, a * drawn later: a press on a point picks it and drags it, Alt makes it round or square again, a
* press on a handle drags that, and a double-click on a line puts a point there (#288). Inside a * press on a handle drags that, and a double-click on a line puts a point there (#288). Inside a
* shape's fill, and on its line, the whole shape is dragged; on nothing the selection is cleared. * shape's fill, and on its line, the whole shape is dragged; on nothing the selection is cleared.
* Ctrl adds a shape to the selection or takes it out again, which is how shapes are picked for * Ctrl or Shift adds a shape to the selection or takes it out again, as on a row of the list,
* combining (#187). * which is how shapes are picked for combining (#187). A shape that stands in a group brings the
* whole group along, and a shape of the selection carries all of it (#190) – so a group is moved
* by dragging any of its shapes.
*/ */
private pressSelect( private pressSelect(
asset: VectorArtAsset, asset: VectorArtAsset,
@@ -412,7 +439,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
tolerance: number, tolerance: number,
e: FederatedPointerEvent, e: FederatedPointerEvent,
): void { ): void {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey || e.shiftKey) {
const hit = this.shapeAt(asset, at); const hit = this.shapeAt(asset, at);
if (hit) { if (hit) {
this.pick(null); this.pick(null);
@@ -432,7 +459,9 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
const shape = this.shapeAt(asset, at); const shape = this.shapeAt(asset, at);
if (shape) { if (shape) {
this.pick(null); this.pick(null);
this.state.select(shapeSelection(asset.id, [shape.id])); const held = this.selectedShapeIds();
const ids = held.includes(shape.id) ? held : this.withGroup(asset, shape.id);
this.state.select(shapeSelection(asset.id, ids, shape.id));
this.dragShape(shape, at, local); this.dragShape(shape, at, local);
return; return;
} }
@@ -441,21 +470,36 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
this.redraw(); this.redraw();
} }
/** The shape with all that shares its outermost group; alone when it stands in none. */
private withGroup(asset: VectorArtAsset, shapeId: string): string[] {
const group = outermostGroup(asset.layers, shapeId);
return group ? shapesIn(group.items).map((shape) => shape.id) : [shapeId];
}
/** Begins dragging `shape` and, with it, every other selected shape. */
private dragShape(shape: VectorShape, at: Vec2, local: Vec2): void { private dragShape(shape: VectorShape, at: Vec2, local: Vec2): void {
const moved = (one: VectorShape): MovedShape => ({
shapeId: one.id,
before: one.rings,
rings: one.rings.map(cloneVectorRing),
paints: paintsOf(one),
});
const others = this.selectedShapeIds()
.filter((id) => id !== shape.id)
.flatMap((id) => this.shape(id) ?? [])
.map(moved);
this.drag = { this.drag = {
kind: 'shape', kind: 'shape',
shapeId: shape.id, ...moved(shape),
before: shape.rings, others,
rings: shape.rings.map(cloneVectorRing),
ring: -1, ring: -1,
index: -1, index: -1,
side: 'out', side: 'out',
pointer: { x: local.x, y: local.y }, pointer: { x: local.x, y: local.y },
at, at,
moved: false, moved: false,
label: 'Move shape', label: others.length > 0 ? 'Move shapes' : 'Move shape',
delta: { x: 0, y: 0 }, delta: { x: 0, y: 0 },
paints: paintsOf(shape),
}; };
} }
@@ -492,8 +536,10 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
/** The shape under a canvas pixel. Last first: a later shape is drawn on top of an earlier one. */ /** The shape under a canvas pixel. Last first: a later shape is drawn on top of an earlier one. */
private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined { private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined {
for (let i = asset.shapes.length - 1; i >= 0; i--) { // Only what is drawn: a shape on a hidden layer is not there to be pressed.
const shape = asset.shapes[i]!; const shapes = drawnShapes(asset);
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i]!;
if (shape.rings.some((ring) => insideRing(ring, at))) return shape; if (shape.rings.some((ring) => insideRing(ring, at))) return shape;
} }
return undefined; return undefined;
@@ -543,6 +589,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
label: grip.kind === 'handle' ? 'Bend shape' : 'Move point', label: grip.kind === 'handle' ? 'Bend shape' : 'Move point',
delta: { x: 0, y: 0 }, delta: { x: 0, y: 0 },
paints: {}, paints: {},
others: [],
}; };
this.redraw(); this.redraw();
} }
@@ -564,9 +611,9 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
const ring = drag.rings[drag.ring]; const ring = drag.rings[drag.ring];
if (drag.kind === 'shape') { if (drag.kind === 'shape') {
const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y }); const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y });
const before = drag.before;
drag.delta = delta; drag.delta = delta;
drag.rings = before.map((r) => ringMoved(r, delta.x, delta.y)); for (const part of [drag, ...drag.others])
part.rings = part.before.map((r) => ringMoved(r, delta.x, delta.y));
} else if (ring && drag.kind === 'handle') { } else if (ring && drag.kind === 'handle') {
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to)); drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
} else if (ring && drag.kind === 'pen') { } else if (ring && drag.kind === 'pen') {
@@ -618,15 +665,17 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
return; return;
} }
const drag = this.drag; const drag = this.drag;
const shape = drag && this.shape(drag.shapeId); if (!drag || !this.shape(drag.shapeId)) return;
if (!drag || !shape) return; for (const part of [drag, ...drag.others]) {
shape.rings = drag.rings; const shape = this.shape(part.shapeId);
// A whole shape takes its gradients along (#186), handles and all. if (!shape) continue;
if (drag.kind === 'shape') { shape.rings = part.rings;
// A whole shape takes its gradients along (#186), handles and all.
if (drag.kind !== 'shape') continue;
const delta = drag.delta; const delta = drag.delta;
if (drag.paints.paint) shape.paint = paintMoved(drag.paints.paint, delta.x, delta.y); if (part.paints.paint) shape.paint = paintMoved(part.paints.paint, delta.x, delta.y);
if (drag.paints.strokePaint && shape.stroke) if (part.paints.strokePaint && shape.stroke)
shape.stroke.paint = paintMoved(drag.paints.strokePaint, delta.x, delta.y); shape.stroke.paint = paintMoved(part.paints.strokePaint, delta.x, delta.y);
} }
this.state.preview(); this.state.preview();
} }
@@ -640,30 +689,36 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
this.drag = null; this.drag = null;
// The gesture is over, so a preview still waiting for its frame has nothing left to write. // The gesture is over, so a preview still waiting for its frame has nothing left to write.
cancelFrameWork(this.frameKey); cancelFrameWork(this.frameKey);
const shape = drag && this.shape(drag.shapeId); if (!drag || !this.shape(drag.shapeId)) return;
if (!drag || !shape) return;
// The preview moved it already; put it back so the command records the state that was there.
shape.rings = drag.before;
if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring }; if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring };
const moved = drag.kind === 'shape' ? this.movedPaints(drag) : null; const commands: SetVectorShapeRingsCommand[] = [];
if (moved) { for (const part of [drag, ...drag.others]) {
if (drag.paints.paint) shape.paint = drag.paints.paint; const shape = this.shape(part.shapeId);
if (drag.paints.strokePaint && shape.stroke) shape.stroke.paint = drag.paints.strokePaint; if (!shape) continue;
// The preview moved it already; put it back so the command records the state that was there.
shape.rings = part.before;
const moved = drag.kind === 'shape' ? this.movedPaints(part, drag.delta) : null;
if (moved) {
if (part.paints.paint) shape.paint = part.paints.paint;
if (part.paints.strokePaint && shape.stroke) shape.stroke.paint = part.paints.strokePaint;
}
if (SetVectorShapeRingsCommand.changes(shape, part.rings))
commands.push(
new SetVectorShapeRingsCommand(this.assetId, part.shapeId, part.rings, drag.label, moved),
);
} }
if (SetVectorShapeRingsCommand.changes(shape, drag.rings)) // Several shapes dragged together are one undo step.
this.state.execute( if (commands.length > 0) this.state.execute(oneStep(drag.label, commands));
new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label, moved),
);
else this.state.preview(); else this.state.preview();
} }
/** The paints of a dragged shape where the drag leaves them; null when it has none to move. */ /** The paints of a dragged shape where the drag leaves them; null when it has none to move. */
private movedPaints(drag: ShapeDrag): ReturnType<typeof paintsOf> | null { private movedPaints(part: MovedShape, delta: Vec2): ReturnType<typeof paintsOf> | null {
const { paint, strokePaint } = drag.paints; const { paint, strokePaint } = part.paints;
if (!paint && !strokePaint) return null; if (!paint && !strokePaint) return null;
return { return {
...(paint ? { paint: paintMoved(paint, drag.delta.x, drag.delta.y) } : {}), ...(paint ? { paint: paintMoved(paint, delta.x, delta.y) } : {}),
...(strokePaint ? { strokePaint: paintMoved(strokePaint, drag.delta.x, drag.delta.y) } : {}), ...(strokePaint ? { strokePaint: paintMoved(strokePaint, delta.x, delta.y) } : {}),
}; };
} }
@@ -706,7 +761,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
// Its line carries the selected shape as its fill does; a point or a handle is its own grip. // Its line carries the selected shape as its fill does; a point or a handle is its own grip.
const onLine = grips.includes('segment'); const onLine = grips.includes('segment');
const onGrip = grips.includes('point') || grips.includes('handle'); const onGrip = grips.includes('point') || grips.includes('handle');
const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at))); const inside = this.shapeAt(asset, at) !== undefined;
this.cursor = (inside || onLine) && !onGrip ? 'move' : Theme.hoverCursor; this.cursor = (inside || onLine) && !onGrip ? 'move' : Theme.hoverCursor;
} }
@@ -860,10 +915,12 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
: this.tool === 'pen' : this.tool === 'pen'
? this.drawing ? this.drawing
? 'Pen: click adds a point, drag bends it, click the first point to close, Esc ends the line.' ? 'Pen: click adds a point, drag bends it, click the first point to close, Esc ends the line.'
: 'Pen: click to start a shape.' : activeLayerOf(this.state, asset)?.hidden
? 'Pen: this layer is hidden – show it in the inspector to draw on it.'
: 'Pen: click to start a shape.'
: this.tool === 'paint' : this.tool === 'paint'
? this.paintHint() ? this.paintHint()
: asset.shapes.length === 0 : allShapes(asset).length === 0
? 'Nothing drawn yet – take the Pen and click on the canvas.' ? 'Nothing drawn yet – take the Pen and click on the canvas.'
: ''; : '';
this.hint.text = text; this.hint.text = text;
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Asset, VectorArtAsset } from '../../core/model/Asset'; import type { Asset, VectorArtAsset } from '../../core/model/Asset';
import { vectorCanvasSize } from '../../core/model/vectorart'; import { vectorCanvasSize } from '../../core/model/vectorart';
import { allShapes } from '../../core/model/vectorTree';
import { Button, Dropdown, Menu, type Stack } from '../../ui'; import { Button, Dropdown, Menu, type Stack } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { SnapToggle } from '../graph/SnapToggle'; import { SnapToggle } from '../graph/SnapToggle';
@@ -95,7 +96,7 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
protected override infoText(asset: VectorArtAsset): string { protected override infoText(asset: VectorArtAsset): string {
const { width, height } = vectorCanvasSize(asset.props); const { width, height } = vectorCanvasSize(asset.props);
const count = asset.shapes.length; const count = allShapes(asset).length;
return `${width} × ${height} px · ${count} ${count === 1 ? 'shape' : 'shapes'}`; return `${width} × ${height} px · ${count} ${count === 1 ? 'shape' : 'shapes'}`;
} }
+38
View File
@@ -0,0 +1,38 @@
/**
* Which layer of a vector drawing is the one being worked on (#190): the pen draws onto it and the
* inspector lists its shapes. A selected shape says it by where it stands; while none is selected it
* is the layer picked last, and the topmost one before any was picked.
*
* Kept by asset and outside the selection, so picking the drawing itself does not forget it.
*/
import type { VectorArtAsset, VectorLayer } from '../../core/model/Asset';
import { layerOfItem } from '../../core/model/vectorTree';
import type { EditorState } from '../state';
const picked = new Map<string, string>();
/** `shapeId` is the selected shape of this drawing, if one is. */
export function activeVectorLayer(asset: VectorArtAsset, shapeId = ''): VectorLayer | undefined {
const selected = shapeId ? layerOfItem(asset, shapeId) : undefined;
if (selected) {
picked.set(asset.id, selected.id);
return selected;
}
return (
asset.layers.find((layer) => layer.id === picked.get(asset.id)) ?? asset.layers[asset.layers.length - 1]
);
}
/** The same, read off the selection. */
export function activeLayerOf(state: EditorState, asset: VectorArtAsset): VectorLayer | undefined {
const s = state.selection;
return activeVectorLayer(asset, s?.kind === 'shape' && s.assetId === asset.id ? s.shapeId : '');
}
/** Makes a layer the active one; the shapes of another layer cannot stay selected then. */
export function pickVectorLayer(state: EditorState, assetId: string, layerId: string): void {
picked.set(assetId, layerId);
const s = state.selection;
if (s?.kind === 'shape' && s.assetId === assetId) state.select({ kind: 'asset', assetId });
state.preview();
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Grouping the selected shapes of a vector drawing (#190) – what the inspector's two buttons and
* Ctrl+G both do.
*/
import { GroupVectorShapesCommand, UngroupVectorShapesCommand } from '../../core/commands/vectorArtCommands';
import type { VectorArtAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findVectorItem, isVectorGroup, placeOf, selectedItems } from '../../core/model/vectorTree';
import type { EditorState } from '../state';
/** Why the selected shapes cannot be grouped, or null when they can. */
export function groupBlocker(asset: VectorArtAsset, shapeIds: readonly string[]): string | null {
const items = selectedItems(asset.layers, shapeIds);
if (items.length === 0) return 'Nothing is selected.';
const layers = new Set(items.map((id) => placeOf(asset.layers, id)?.layer.id));
return layers.size > 1 ? 'Shapes of different layers cannot be grouped.' : null;
}
/** The groups Ungroup takes away: those picked as a whole, else the one the primary shape is in. */
export function groupsToDissolve(
asset: VectorArtAsset,
shapeId: string,
shapeIds: readonly string[],
): string[] {
const picked = selectedItems(asset.layers, shapeIds).filter((id) => {
const item = findVectorItem(asset, id);
return item !== undefined && isVectorGroup(item);
});
if (picked.length > 0) return picked;
const parent = placeOf(asset.layers, shapeId)?.parent;
return parent ? [parent.id] : [];
}
/** Groups the selection; the shapes stay selected, so the new group reads as picked. */
export function groupShapes(state: EditorState, assetId: string): boolean {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const s = state.selection;
if (!asset || s?.kind !== 'shape' || s.assetId !== assetId) return false;
if (groupBlocker(asset, s.shapeIds) !== null) return false;
return state.execute(new GroupVectorShapesCommand(assetId, selectedItems(asset.layers, s.shapeIds)));
}
export function ungroupShapes(state: EditorState, assetId: string): boolean {
const asset = findAssetOf(state.project, assetId, 'vectorart');
const s = state.selection;
if (!asset || s?.kind !== 'shape' || s.assetId !== assetId) return false;
const groups = groupsToDissolve(asset, s.shapeId, s.shapeIds);
return groups.length > 0 && state.execute(new UngroupVectorShapesCommand(assetId, groups));
}
+64 -18
View File
@@ -46,6 +46,8 @@ const REMOVE_SLOT = 24;
const VALUE_WIDTH = 44; const VALUE_WIDTH = 44;
/** Height of a header that only holds the plus; one with a title is a band (see `sections.ts`). */ /** Height of a header that only holds the plus; one with a title is a band (see `sections.ts`). */
const HEADER_HEIGHT = 24; const HEADER_HEIGHT = 24;
/** How far a row stands in for each group it is inside of. */
export const SORTABLE_INDENT = 14;
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */ /** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
const DRAG_THRESHOLD = 4; const DRAG_THRESHOLD = 4;
@@ -56,6 +58,8 @@ export interface SortableItem {
hint?: string; hint?: string;
/** Drawn faded, with a closed eye. */ /** Drawn faded, with a closed eye. */
hidden?: boolean; hidden?: boolean;
/** How many groups the item is inside of, in a list that shows a tree; its row stands in that far. */
depth?: number;
} }
/** Part of the picture square that is shown, in its own coordinates. */ /** Part of the picture square that is shown, in its own coordinates. */
@@ -94,6 +98,14 @@ export interface SortableListOptions {
onPick: (id: string, toggle: boolean) => void; onPick: (id: string, toggle: boolean) => void;
/** A row dropped somewhere else, in the owner's indices. */ /** A row dropped somewhere else, in the owner's indices. */
onMove: (from: number, to: number) => void; onMove: (from: number, to: number) => void;
/**
* For a list that shows a tree: a drop is reported here instead of to `onMove`, as the gap between
* the rows it fell into (0 is above the first) and the depth the pointer asked for – the dragged
* row's own, plus one for every indent the pointer went right.
*/
onDrop?: (id: string, gap: number, depth: number) => void;
/** With `onDrop`: the depth that drop would land at, for the marker; null where it cannot land. */
dropDepth?: (id: string, gap: number, depth: number) => number | null;
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */ /** Setting this gives every row an eye; a press on it reports the item and nothing else. */
onToggleHidden?: (id: string) => void; onToggleHidden?: (id: string) => void;
/** Setting this gives every row a duplicate mark left of the cross; a press on it reports the item. */ /** Setting this gives every row a duplicate mark left of the cross; a press on it reports the item. */
@@ -139,6 +151,8 @@ interface RowDrag {
/** Both in the rows' own coordinates, below the header. */ /** Both in the rows' own coordinates, below the header. */
startY: number; startY: number;
y: number; y: number;
startX: number;
x: number;
active: boolean; active: boolean;
} }
@@ -269,14 +283,14 @@ export class SortableList extends Widget implements SectionStart {
thumbGlobal(id: string): { x: number; y: number } | null { thumbGlobal(id: string): { x: number; y: number } | null {
const row = this.rowGlobal(id); const row = this.rowGlobal(id);
if (!row || !this.options.thumb) return null; if (!row || !this.options.thumb) return null;
return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) }; return { x: Math.round(row.x + this.indentOf(id) + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) };
} }
/** A point on an item's name, where a double-click edits it (test hook); null without that row. */ /** A point on an item's name, where a double-click edits it (test hook); null without that row. */
nameGlobal(id: string): { x: number; y: number } | null { nameGlobal(id: string): { x: number; y: number } | null {
const row = this.rowGlobal(id); const row = this.rowGlobal(id);
if (!row) return null; if (!row) return null;
return { x: Math.round(row.x + this.textX + 12), y: Math.round(row.y + row.h / 2) }; return { x: Math.round(row.x + this.indentOf(id) + this.textX + 12), y: Math.round(row.y + row.h / 2) };
} }
/** The centre of an item's eye (test hook); null without eyes or without that row. */ /** The centre of an item's eye (test hook); null without eyes or without that row. */
@@ -328,11 +342,15 @@ export class SortableList extends Widget implements SectionStart {
return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1); return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1);
} }
/** Where the name starts. */ /** Where the name starts in a row that is in no group. */
private get textX(): number { private get textX(): number {
return this.options.thumb ? PAD * 2 + THUMB : PAD * 2; return this.options.thumb ? PAD * 2 + THUMB : PAD * 2;
} }
private indentOf(id: string): number {
return (this.shown.find((item) => item.id === id)?.depth ?? 0) * SORTABLE_INDENT;
}
/** /**
* The right edge of each part of a row, from the right: the cross, the duplicate mark, the eye and * The right edge of each part of a row, from the right: the cross, the duplicate mark, the eye and
* the number. A part a list does not have takes no room. * the number. A part a list does not have takes no room.
@@ -404,15 +422,15 @@ export class SortableList extends Widget implements SectionStart {
} }
const row = this.rowAt(local.y); const row = this.rowAt(local.y);
if (row < 0) return null; if (row < 0) return null;
return { row, part: this.partAt(local.x) }; return { row, part: this.partAt(local.x, (this.shown[row]?.depth ?? 0) * SORTABLE_INDENT) };
} }
private partAt(x: number): RowPart { private partAt(x: number, indent: number): RowPart {
const slots = this.slots; const slots = this.slots;
if (this.canRemove && x >= slots.duplicate) return 'cross'; if (this.canRemove && x >= slots.duplicate) return 'cross';
if (this.options.onDuplicate && x >= slots.eye && x < slots.duplicate) return 'duplicate'; if (this.options.onDuplicate && x >= slots.eye && x < slots.duplicate) return 'duplicate';
if (this.options.onToggleHidden && x >= slots.value && x < slots.eye) return 'eye'; if (this.options.onToggleHidden && x >= slots.value && x < slots.eye) return 'eye';
if (this.options.onRename && x >= this.textX && x < this.nameRight) return 'name'; if (this.options.onRename && x >= indent + this.textX && x < this.nameRight) return 'name';
return 'row'; return 'row';
} }
@@ -456,7 +474,7 @@ export class SortableList extends Widget implements SectionStart {
// Picked on release: a pick rebuilds what the list stands in, which would end the drag. // Picked on release: a pick rebuilds what the list stands in, which would end the drag.
const pick = this.selected.length !== 1 || this.selected[0] !== id; const pick = this.selected.length !== 1 || this.selected[0] !== id;
const y = local.y - this.headerHeight; const y = local.y - this.headerHeight;
this.drag = { from: hit.row, pick, startY: y, y, active: false }; this.drag = { from: hit.row, pick, startY: y, y, startX: local.x, x: local.x, active: false };
} }
private startRename(index: number): void { private startRename(index: number): void {
@@ -488,7 +506,7 @@ export class SortableList extends Widget implements SectionStart {
const renaming = this.renaming; const renaming = this.renaming;
if (!renaming) return; if (!renaming) return;
const index = this.shown.findIndex((item) => item.id === renaming.id); const index = this.shown.findIndex((item) => item.id === renaming.id);
const x = this.textX - PAD; const x = this.indentOf(renaming.id) + this.textX - PAD;
renaming.field.position.set( renaming.field.position.set(
x, x,
this.headerHeight + index * SORTABLE_ROW_HEIGHT + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2, this.headerHeight + index * SORTABLE_ROW_HEIGHT + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2,
@@ -499,8 +517,14 @@ export class SortableList extends Widget implements SectionStart {
private onMove(e: FederatedPointerEvent): void { private onMove(e: FederatedPointerEvent): void {
const drag = this.drag; const drag = this.drag;
if (!drag) return; if (!drag) return;
drag.y = this.toLocal(e.global).y - this.headerHeight; const local = this.toLocal(e.global);
if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return; drag.y = local.y - this.headerHeight;
drag.x = local.x;
const far = Math.max(
Math.abs(drag.y - drag.startY),
this.options.onDrop ? Math.abs(drag.x - drag.startX) : 0,
);
if (!drag.active && far < DRAG_THRESHOLD) return;
drag.active = true; drag.active = true;
this.invalidate(); this.invalidate();
} }
@@ -513,12 +537,22 @@ export class SortableList extends Widget implements SectionStart {
const id = this.shown[drag.from]?.id; const id = this.shown[drag.from]?.id;
if (drag.active) { if (drag.active) {
const gap = dropGap(this.shown.length, drag.y, SORTABLE_ROW_HEIGHT); const gap = dropGap(this.shown.length, drag.y, SORTABLE_ROW_HEIGHT);
const move = dropMove(this.shown.length, drag.from, gap, this.options.reversed ?? false); if (this.options.onDrop) {
if (move) this.options.onMove(move.from, move.to); if (id) this.options.onDrop(id, gap, this.askedDepth(drag));
} else {
const move = dropMove(this.shown.length, drag.from, gap, this.options.reversed ?? false);
if (move) this.options.onMove(move.from, move.to);
}
} }
if (drag.pick && id) this.options.onPick(id, false); if (drag.pick && id) this.options.onPick(id, false);
} }
/** The depth a drag asks for: the row's own, one more for every indent the pointer went right. */
private askedDepth(drag: RowDrag): number {
const own = this.shown[drag.from]?.depth ?? 0;
return own + Math.round((drag.x - drag.startX) / SORTABLE_INDENT);
}
override redraw(): void { override redraw(): void {
const top = this.headerHeight; const top = this.headerHeight;
this.background.clear(); this.background.clear();
@@ -550,14 +584,15 @@ export class SortableList extends Widget implements SectionStart {
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT) .rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover); .fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
const indent = (item.depth ?? 0) * SORTABLE_INDENT;
const thumbY = (SORTABLE_ROW_HEIGHT - THUMB) / 2; const thumbY = (SORTABLE_ROW_HEIGHT - THUMB) / 2;
row.art.clear(); row.art.clear();
row.art.position.set(PAD, thumbY); row.art.position.set(indent + PAD, thumbY);
row.art.scale.set(1); row.art.scale.set(1);
row.clip.clear(); row.clip.clear();
if (thumbs) { if (thumbs) {
const clip = thumbs(item.id, row.art, THUMB) ?? { x: 0, y: 0, w: THUMB, h: THUMB }; const clip = thumbs(item.id, row.art, THUMB) ?? { x: 0, y: 0, w: THUMB, h: THUMB };
row.clip.rect(PAD + clip.x, thumbY + clip.y, clip.w, clip.h).fill(0xffffff); row.clip.rect(indent + PAD + clip.x, thumbY + clip.y, clip.w, clip.h).fill(0xffffff);
} }
row.art.alpha = item.hidden ? 0.35 : 1; row.art.alpha = item.hidden ? 0.35 : 1;
@@ -583,8 +618,12 @@ export class SortableList extends Widget implements SectionStart {
const renamed = this.renaming?.id === item.id; const renamed = this.renaming?.id === item.id;
row.label.visible = !renamed; row.label.visible = !renamed;
row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted'); row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted');
row.label.text = truncate(item.label, row.label.style, Math.max(0, nameRight - textX - hintW - PAD)); row.label.text = truncate(
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2); item.label,
row.label.style,
Math.max(0, nameRight - indent - textX - hintW - PAD),
);
row.label.position.set(indent + textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
const mid = SORTABLE_ROW_HEIGHT / 2; const mid = SORTABLE_ROW_HEIGHT / 2;
row.eye.clear(); row.eye.clear();
@@ -607,8 +646,15 @@ export class SortableList extends Widget implements SectionStart {
} }
this.placeRenameField(); this.placeRenameField();
if (this.drag?.active) { if (this.drag?.active) {
const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT; const gap = dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT);
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent); const y = top + gap * SORTABLE_ROW_HEIGHT;
const id = this.shown[this.drag.from]?.id ?? '';
// In a tree the marker starts where the row would: that is how one sees which group it joins.
const depth = this.options.dropDepth ? this.options.dropDepth(id, gap, this.askedDepth(this.drag)) : 0;
if (depth !== null) {
const x = depth * SORTABLE_INDENT;
this.marker.rect(x, Math.min(y, this.h - 2), this.w - x, 2).fill(Theme.colors.accent);
}
} }
} }
+12 -11
View File
@@ -22,6 +22,7 @@ import {
flattenRing, flattenRing,
type ShapeBoolean, type ShapeBoolean,
} from '../../src/core/model/vectorBoolean'; } from '../../src/core/model/vectorBoolean';
import { allShapes } from '../../src/core/model/vectorTree';
import { makeProject } from '../helpers'; import { makeProject } from '../helpers';
function square(x: number, y: number, side: number): VectorRing { function square(x: number, y: number, side: number): VectorRing {
@@ -280,38 +281,38 @@ describe('the combine command', () => {
const combine = new CombineVectorShapesCommand(f.assetId, [high, low], 'subtract'); const combine = new CombineVectorShapesCommand(f.assetId, [high, low], 'subtract');
f.stack.execute(combine); f.stack.execute(combine);
expect(combine.shapeId).toBe(low); expect(combine.shapeId).toBe(low);
expect(f.art().shapes.map((s) => s.id)).toEqual([first, low]); expect(allShapes(f.art()).map((s) => s.id)).toEqual([first, low]);
const result = f.art().shapes[1]!; const result = allShapes(f.art())[1]!;
expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } }); expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } });
expect(paintedArea(result.rings)).toBe(300); expect(paintedArea(result.rings)).toBe(300);
}); });
it('is one undo step that puts every shape back as it was', () => { it('is one undo step that puts every shape back as it was', () => {
const f = drawingWith(square(0, 0, 20), square(10, 10, 20), square(40, 40, 10)); const f = drawingWith(square(0, 0, 20), square(10, 10, 20), square(40, 40, 10));
const before = JSON.stringify(f.art().shapes); const before = JSON.stringify(allShapes(f.art()));
f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union')); f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union'));
expect(f.art().shapes).toHaveLength(1); expect(allShapes(f.art())).toHaveLength(1);
f.stack.undo(); f.stack.undo();
expect(JSON.stringify(f.art().shapes)).toBe(before); expect(JSON.stringify(allShapes(f.art()))).toBe(before);
f.stack.redo(); f.stack.redo();
expect(paintedArea(f.art().shapes[0]!.rings)).toBe(800); expect(paintedArea(allShapes(f.art())[0]!.rings)).toBe(800);
}); });
it('refuses a result of nothing and leaves the drawing alone', () => { it('refuses a result of nothing and leaves the drawing alone', () => {
const f = drawingWith(square(0, 0, 10), square(30, 30, 10)); const f = drawingWith(square(0, 0, 10), square(30, 30, 10));
const before = JSON.stringify(f.art().shapes); const before = JSON.stringify(allShapes(f.art()));
expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow( expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow(
/nothing in common/, /nothing in common/,
); );
expect(JSON.stringify(f.art().shapes)).toBe(before); expect(JSON.stringify(allShapes(f.art()))).toBe(before);
}); });
it('removes several shapes in one step and brings them back in their places', () => { it('removes several shapes in one step and brings them back in their places', () => {
const f = drawingWith(square(0, 0, 5), square(10, 0, 5), square(20, 0, 5)); const f = drawingWith(square(0, 0, 5), square(10, 0, 5), square(20, 0, 5));
const before = f.art().shapes.map((s) => s.id); const before = allShapes(f.art()).map((s) => s.id);
f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!])); f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!]));
expect(f.art().shapes.map((s) => s.id)).toEqual([f.ids[1]]); expect(allShapes(f.art()).map((s) => s.id)).toEqual([f.ids[1]]);
f.stack.undo(); f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(before); expect(allShapes(f.art()).map((s) => s.id)).toEqual(before);
}); });
}); });
+19 -16
View File
@@ -33,6 +33,7 @@ import {
} from '../../src/core/model/vectorPaint'; } from '../../src/core/model/vectorPaint';
import { shapeDrawing } from '../../src/core/model/vectorPath'; import { shapeDrawing } from '../../src/core/model/vectorPath';
import { makeProject, roundTrip } from '../helpers'; import { makeProject, roundTrip } from '../helpers';
import { allShapes } from '../../src/core/model/vectorTree';
const FILE = `${'c'.repeat(64)}.png`; const FILE = `${'c'.repeat(64)}.png`;
@@ -214,7 +215,7 @@ describe('drawing a paint', () => {
describe('a paint in the picture key and the project file', () => { describe('a paint in the picture key and the project file', () => {
it('is a new drawing when a stop, an end or the picture behind a texture changes', () => { it('is a new drawing when a stop, an end or the picture behind a texture changes', () => {
const f = withTexture(); const f = withTexture();
const shapeOf = () => f.drawing().shapes[0]!; const shapeOf = () => allShapes(f.drawing())[0]!;
const plain = vectorArtRevision(f.drawing(), f.drawing().props, f.project); const plain = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
shapeOf().paint = gradient(); shapeOf().paint = gradient();
const linear = vectorArtRevision(f.drawing(), f.drawing().props, f.project); const linear = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
@@ -231,10 +232,10 @@ describe('a paint in the picture key and the project file', () => {
it('comes back from a project file as it was written', () => { it('comes back from a project file as it was written', () => {
const f = withTexture(); const f = withTexture();
const shapeNow = f.drawing().shapes[0]!; const shapeNow = allShapes(f.drawing())[0]!;
shapeNow.paint = gradient({ type: 'radial' }); shapeNow.paint = gradient({ type: 'radial' });
shapeNow.stroke = { color: 0xff0000, width: 2, paint: gradient() }; shapeNow.stroke = { color: 0xff0000, width: 2, paint: gradient() };
const back = f.drawing(roundTrip(f.project)).shapes[0]!; const back = allShapes(f.drawing(roundTrip(f.project)))[0]!;
expect(back.paint).toEqual(shapeNow.paint); expect(back.paint).toEqual(shapeNow.paint);
expect(back.stroke).toEqual(shapeNow.stroke); expect(back.stroke).toEqual(shapeNow.stroke);
expect(vectorArtRevision(f.drawing(roundTrip(f.project)))).toBe(vectorArtRevision(f.drawing())); expect(vectorArtRevision(f.drawing(roundTrip(f.project)))).toBe(vectorArtRevision(f.drawing()));
@@ -242,9 +243,11 @@ describe('a paint in the picture key and the project file', () => {
it('drops a paint it cannot draw and keeps the colour the shape had', () => { it('drops a paint it cannot draw and keeps the colour the shape had', () => {
const f = withTexture(); const f = withTexture();
const raw = JSON.parse(JSON.stringify(f.project)) as { assets: { shapes?: unknown[] }[] }; const raw = JSON.parse(JSON.stringify(f.project)) as {
const drawing = raw.assets.find((a) => Array.isArray(a.shapes))!; assets: { type: string; layers?: { items: unknown[] }[] }[];
const written = drawing.shapes![0] as Record<string, unknown>; };
const drawing = raw.assets.find((a) => a.type === 'vectorart')!;
const written = drawing.layers![0]!.items[0] as Record<string, unknown>;
written.paint = { written.paint = {
type: 'linear', type: 'linear',
from: { x: 0, y: 0 }, from: { x: 0, y: 0 },
@@ -256,7 +259,7 @@ describe('a paint in the picture key and the project file', () => {
width: 1, width: 1,
paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, texture: 'x' }, paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, texture: 'x' },
}; };
const back = f.drawing(roundTrip(raw as unknown as Project)).shapes[0]!; const back = allShapes(f.drawing(roundTrip(raw as unknown as Project)))[0]!;
expect(back.paint).toBeUndefined(); expect(back.paint).toBeUndefined();
expect(back.stroke).toEqual({ color: 5, width: 1 }); expect(back.stroke).toEqual({ color: 5, width: 1 });
}); });
@@ -266,13 +269,13 @@ describe('writing a paint', () => {
it('sets and removes a fill paint as one undo step each', () => { it('sets and removes a fill paint as one undo step each', () => {
const f = withTexture(); const f = withTexture();
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: gradient() })); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: gradient() }));
expect(f.drawing().shapes[0]!.paint).toEqual(gradient()); expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: null })); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: null }));
expect(f.drawing().shapes[0]!.paint).toBeUndefined(); expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
f.stack.undo(); f.stack.undo();
expect(f.drawing().shapes[0]!.paint).toEqual(gradient()); expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
f.stack.undo(); f.stack.undo();
expect(f.drawing().shapes[0]!.paint).toBeUndefined(); expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
}); });
it('refuses a gradient of one colour, on the fill and on the outline', () => { it('refuses a gradient of one colour, on the fill and on the outline', () => {
@@ -289,7 +292,7 @@ describe('writing a paint', () => {
it('moves the paints along when a whole shape is dragged, and undo takes both back', () => { it('moves the paints along when a whole shape is dragged, and undo takes both back', () => {
const f = withTexture(); const f = withTexture();
const shapeNow = f.drawing().shapes[0]!; const shapeNow = allShapes(f.drawing())[0]!;
shapeNow.paint = gradient(); shapeNow.paint = gradient();
shapeNow.stroke = { color: 0, width: 1, paint: gradient({ type: 'radial' }) }; shapeNow.stroke = { color: 0, width: 1, paint: gradient({ type: 'radial' }) };
const rings = [{ ...SQUARE, points: SQUARE.points.map((p) => ({ x: p.x + 10, y: p.y })) }]; const rings = [{ ...SQUARE, points: SQUARE.points.map((p) => ({ x: p.x + 10, y: p.y })) }];
@@ -298,10 +301,10 @@ describe('writing a paint', () => {
strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0), strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0),
}; };
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, f.shapeId, rings, 'Move shape', paints)); f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, f.shapeId, rings, 'Move shape', paints));
expect(f.drawing().shapes[0]!.paint?.from).toEqual({ x: 10, y: 0 }); expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 10, y: 0 });
expect(f.drawing().shapes[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 }); expect(allShapes(f.drawing())[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 });
f.stack.undo(); f.stack.undo();
expect(f.drawing().shapes[0]!.paint?.from).toEqual({ x: 0, y: 0 }); expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 0, y: 0 });
expect(f.drawing().shapes[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 }); expect(allShapes(f.drawing())[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 });
}); });
}); });
+7 -1
View File
@@ -27,7 +27,13 @@ function shape(over: Partial<VectorShape> = {}): VectorShape {
} }
function art(shapes: VectorShape[], props: PropertyBag = {}): VectorArtAsset { function art(shapes: VectorShape[], props: PropertyBag = {}): VectorArtAsset {
return { id: 'a1', type: 'vectorart', name: 'Logo', props, shapes }; return {
id: 'a1',
type: 'vectorart',
name: 'Logo',
props,
layers: [{ id: 'l1', name: 'Layer 1', items: shapes }],
};
} }
describe('the path of a ring', () => { describe('the path of a ring', () => {
+505
View File
@@ -0,0 +1,505 @@
/**
* Layers and groups of vector art (#190): the tree as it is read, every way it changes, the commands
* that change it with their undo, what a project file gives back and what is in the picture.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import {
AddVectorLayerCommand,
AddVectorShapeCommand,
CombineVectorShapesCommand,
DuplicateVectorShapeCommand,
GroupVectorShapesCommand,
MoveVectorLayerCommand,
MoveVectorShapesCommand,
OrderVectorShapeCommand,
RemoveVectorLayerCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
RenameVectorLayerCommand,
RenameVectorShapeCommand,
SetVectorLayerHiddenCommand,
SetVectorShapeRingsCommand,
UngroupVectorShapesCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorItem, VectorLayer, VectorShape } from '../../src/core/model/Asset';
import { findAsset, type Project } from '../../src/core/model/Project';
import { vectorArtRevision } from '../../src/core/model/vectorart';
import { vectorArtDrawings } from '../../src/core/model/vectorPath';
import {
allShapes,
drawnShapes,
outermostGroup,
placeOf,
selectedItems,
treeDrop,
treeDropDepth,
type TreeRow,
treeRows,
VectorTreeError,
withGroupDissolved,
withItemsGrouped,
withItemsMoved,
withItemsRemoved,
withLayerRemoved,
} from '../../src/core/model/vectorTree';
import { makeProject, roundTrip } from '../helpers';
function shape(id: string, x = 0): VectorShape {
return {
id,
name: id.toUpperCase(),
fill: 0x112233,
opacity: 1,
rings: [
{
closed: true,
points: [
{ x, y: 0 },
{ x: x + 10, y: 0 },
{ x, y: 10 },
],
},
],
};
}
const group = (id: string, ...items: VectorItem[]): VectorItem => ({ id, name: id.toUpperCase(), items });
const layer = (id: string, ...items: VectorItem[]): VectorLayer => ({ id, name: id, items });
/** How a tree reads, groups in brackets: `a g[b h[c]] d`. */
function text(items: readonly VectorItem[]): string {
return items.map((item) => ('items' in item ? `${item.id}[${text(item.items)}]` : item.id)).join(' ');
}
const layersText = (layers: readonly VectorLayer[]) => layers.map((l) => `${l.id}: ${text(l.items)}`);
/** a, then group g of b and the group h of c, then d – on one layer. */
const nested = () => [layer('l1', shape('a'), group('g', shape('b'), group('h', shape('c'))), shape('d'))];
const row = (id: string, depth: number, isGroup = false): TreeRow => ({
id,
name: id.toUpperCase(),
depth,
group: isGroup,
});
describe('reading the tree', () => {
it('hands the shapes out in drawing order, groups walked in place', () => {
const art = { layers: [...nested(), layer('l2', shape('e'))] } as VectorArtAsset;
expect(allShapes(art).map((s) => s.id)).toEqual(['a', 'b', 'c', 'd', 'e']);
});
it('leaves the shapes of a hidden layer out of what is drawn, and only there', () => {
const art = {
id: 'art',
type: 'vectorart',
name: 'Art',
props: {},
layers: [{ ...layer('l1', shape('a')), hidden: true }, layer('l2', shape('b'))],
} as VectorArtAsset;
expect(drawnShapes(art).map((s) => s.id)).toEqual(['b']);
expect(allShapes(art).map((s) => s.id)).toEqual(['a', 'b']);
expect(vectorArtDrawings(art).map((d) => d.id)).toEqual(['b']);
});
it('says where an item stands, and which outermost group it is in', () => {
const layers = nested();
expect(placeOf(layers, 'c')).toMatchObject({
index: 0,
depth: 2,
parent: { id: 'h' },
layer: { id: 'l1' },
});
expect(placeOf(layers, 'a')).toMatchObject({ index: 0, depth: 0, parent: null });
expect(placeOf(layers, 'nobody')).toBeNull();
expect(outermostGroup(layers, 'c')?.id).toBe('g');
expect(outermostGroup(layers, 'g')).toBeNull();
expect(outermostGroup(layers, 'a')).toBeNull();
});
it('reads a selection of shapes as the groups it covers completely', () => {
const layers = nested();
expect(selectedItems(layers, ['b', 'c'])).toEqual(['g']);
expect(selectedItems(layers, ['c'])).toEqual(['h']);
expect(selectedItems(layers, ['d', 'b', 'a'])).toEqual(['a', 'b', 'd']);
});
it('lists a layer top row first, a group over what is in it', () => {
expect(treeRows(nested()[0]!.items).map((r) => `${r.id}${r.depth}${r.group ? 'g' : ''}`)).toEqual([
'd0',
'g0g',
'h1g',
'c2',
'b1',
'a0',
]);
});
});
describe('dropping a row', () => {
// d / g / h / c / b / a, as `treeRows` lists `nested()`.
const rows = [row('d', 0), row('g', 0, true), row('h', 1, true), row('c', 2), row('b', 1), row('a', 0)];
it('puts a row between two of the same list', () => {
expect(treeDrop(rows, 'a', 0, 0)).toEqual({ parentId: null, belowId: 'd' });
expect(treeDrop(rows, 'd', 6, 0)).toEqual({ parentId: null, belowId: null });
});
it('drops into a group right under its row, and deeper into the one inside', () => {
expect(treeDrop(rows, 'a', 2, 1)).toEqual({ parentId: 'g', belowId: 'h' });
expect(treeDrop(rows, 'a', 3, 2)).toEqual({ parentId: 'h', belowId: 'c' });
expect(treeDropDepth(rows, 'a', 3, 2)).toBe(2);
});
it('lets the pointer say which list the end of a group belongs to', () => {
// Between c (depth 2) and b (depth 1): the end of h, or g after h.
expect(treeDrop(rows, 'a', 4, 2)).toEqual({ parentId: 'h', belowId: null });
expect(treeDrop(rows, 'a', 4, 1)).toEqual({ parentId: 'g', belowId: 'b' });
// No shallower than the row below: g goes on under this gap.
expect(treeDrop(rows, 'a', 4, 0)).toEqual({ parentId: 'g', belowId: 'b' });
// Between b (depth 1) and a (depth 0), d dragged: the end of g, or the layer after g.
expect(treeDrop(rows, 'd', 5, 1)).toEqual({ parentId: 'g', belowId: null });
expect(treeDrop(rows, 'd', 5, 0)).toEqual({ parentId: null, belowId: 'a' });
expect(treeDropDepth(rows, 'd', 5, 7)).toBe(1);
});
it('takes a row out of its group when it is dropped where it stands, further left', () => {
// b is the last row of g; the gap under it, asked for at depth 0, is the layer.
expect(treeDrop(rows, 'b', 5, 0)).toEqual({ parentId: null, belowId: 'a' });
expect(treeDrop(rows, 'b', 5, 1)).toBeNull();
});
it('has no place where the row already is, inside itself, or for a row it does not know', () => {
expect(treeDrop(rows, 'd', 0, 0)).toBeNull();
expect(treeDrop(rows, 'd', 1, 0)).toBeNull();
expect(treeDrop(rows, 'g', 3, 2)).toBeNull();
expect(treeDrop(rows, 'g', 4, 1)).toBeNull();
expect(treeDropDepth(rows, 'g', 3, 2)).toBeNull();
expect(treeDrop(rows, 'nobody', 0, 0)).toBeNull();
});
});
describe('editing the tree', () => {
it('never touches the layers it was given', () => {
const layers = nested();
const before = JSON.stringify(layers);
withItemsRemoved(layers, ['c']);
withItemsGrouped(layers, ['a', 'd'], { id: 'n', name: 'N' });
withItemsMoved(layers, ['a'], 'l1', { parentId: 'h', belowId: null });
withGroupDissolved(layers, 'g');
expect(JSON.stringify(layers)).toBe(before);
});
it('takes a group away with its last item', () => {
expect(layersText(withItemsRemoved(nested(), ['c']))).toEqual(['l1: a g[b] d']);
expect(layersText(withItemsRemoved(nested(), ['c', 'b']))).toEqual(['l1: a d']);
expect(layersText(withItemsRemoved(nested(), ['g']))).toEqual(['l1: a d']);
expect(() => withItemsRemoved(nested(), ['nobody'])).toThrow(VectorTreeError);
});
it('groups items where the topmost of them stood, in the order they are drawn', () => {
const made = { id: 'n', name: 'N' };
expect(layersText(withItemsGrouped(nested(), ['d', 'a'], made))).toEqual(['l1: g[b h[c]] n[a d]']);
expect(layersText(withItemsGrouped(nested(), ['a', 'b'], made))).toEqual(['l1: g[n[a b] h[c]] d']);
expect(layersText(withItemsGrouped(nested(), ['g', 'a'], made))).toEqual(['l1: n[a g[b h[c]]] d']);
});
it('refuses a group across two layers', () => {
const layers = [...nested(), layer('l2', shape('e'))];
expect(() => withItemsGrouped(layers, ['a', 'e'], { id: 'n', name: 'N' })).toThrow(
'Shapes of different layers cannot be grouped',
);
});
it('dissolves a group into the place it stood in', () => {
expect(layersText(withGroupDissolved(nested(), 'g'))).toEqual(['l1: a b h[c] d']);
expect(layersText(withGroupDissolved(nested(), 'h'))).toEqual(['l1: a g[b c] d']);
expect(() => withGroupDissolved(nested(), 'a')).toThrow(VectorTreeError);
});
it('moves items into a group, out of one, and onto another layer', () => {
const layers = [...nested(), layer('l2', shape('e'))];
expect(layersText(withItemsMoved(layers, ['a'], 'l1', { parentId: 'g', belowId: 'b' }))).toEqual([
'l1: g[b a h[c]] d',
'l2: e',
]);
expect(layersText(withItemsMoved(layers, ['c'], 'l1', { parentId: null, belowId: null }))).toEqual([
'l1: c a g[b] d',
'l2: e',
]);
// Without a place it lands on top of the layer; a group goes as a whole.
expect(layersText(withItemsMoved(layers, ['g', 'a'], 'l2'))).toEqual(['l1: d', 'l2: e a g[b h[c]]']);
});
it('keeps a group whose only item is put back into it', () => {
expect(layersText(withItemsMoved(nested(), ['c'], 'l1', { parentId: 'h', belowId: null }))).toEqual([
'l1: a g[b h[c]] d',
]);
});
it('refuses to move a group into itself or into a group that is not there', () => {
expect(() => withItemsMoved(nested(), ['g'], 'l1', { parentId: 'h', belowId: null })).toThrow(
'A group cannot be moved into itself',
);
expect(() => withItemsMoved(nested(), ['a'], 'l1', { parentId: 'nobody', belowId: null })).toThrow(
VectorTreeError,
);
expect(() => withItemsMoved(nested(), ['a'], 'l1', { parentId: 'd', belowId: null })).toThrow(
VectorTreeError,
);
});
it('keeps the last layer', () => {
expect(() => withLayerRemoved(nested(), 'l1')).toThrow('A drawing needs one layer');
});
});
function drawing() {
const fixture = makeProject();
const create = new CreateAssetCommand('vectorart', 'Logo');
fixture.stack.execute(create);
const assetId = create.asset.id;
const art = (project: Project = fixture.project): VectorArtAsset =>
findAsset(project, assetId) as VectorArtAsset;
const add = (layerId = ''): string => {
const command = new AddVectorShapeCommand(assetId, shape('x').rings, undefined, layerId);
fixture.stack.execute(command);
return command.shapeId;
};
const tree = () => layersText(art().layers).map((line) => line.replace(/^[^:]+: /, ''));
return { ...fixture, assetId, art, add, tree };
}
describe('the commands', () => {
it('starts a drawing with one layer and draws onto the topmost one unless told', () => {
const f = drawing();
expect(f.art().layers.map((l) => l.name)).toEqual(['Layer 1']);
const first = f.add();
const addLayer = new AddVectorLayerCommand(f.assetId);
f.stack.execute(addLayer);
expect(f.art().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
const second = f.add();
const third = f.add(f.art().layers[0]!.id);
expect(f.tree()).toEqual([`${first} ${third}`, second]);
// Numbered across the layers: no two shapes of a drawing share a name.
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 3', 'Shape 2']);
});
it('groups and ungroups in one undo step each, under the same id on redo', () => {
const f = drawing();
const [a, b, c] = [f.add(), f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [a, c]);
f.stack.execute(made);
expect(f.tree()).toEqual([`${b} ${made.groupId}[${a} ${c}]`]);
expect(placeOf(f.art().layers, made.groupId)?.item.name).toBe('Group 1');
f.stack.undo();
expect(f.tree()).toEqual([`${a} ${b} ${c}`]);
f.stack.redo();
expect(f.tree()).toEqual([`${b} ${made.groupId}[${a} ${c}]`]);
f.stack.execute(new UngroupVectorShapesCommand(f.assetId, [made.groupId]));
expect(f.tree()).toEqual([`${b} ${a} ${c}`]);
f.stack.undo();
expect(f.tree()).toEqual([`${b} ${made.groupId}[${a} ${c}]`]);
});
it('brings a removed group back with a shape edited before it went', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [a, b]);
f.stack.execute(made);
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, a, shape('y', 50).rings));
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, made.groupId));
expect(allShapes(f.art())).toEqual([]);
f.stack.undo();
expect(f.tree()).toEqual([`${made.groupId}[${a} ${b}]`]);
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 50, y: 0 });
f.stack.undo();
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0 });
});
it('removes shapes out of a group and takes the emptied group with them', () => {
const f = drawing();
const [a, b, c] = [f.add(), f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [a, b]);
f.stack.execute(made);
f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [a, b]));
expect(f.tree()).toEqual([c]);
f.stack.undo();
expect(f.tree()).toEqual([`${made.groupId}[${a} ${b}] ${c}`]);
});
it('orders an item among those of its own list only', () => {
const f = drawing();
const [a, b, c] = [f.add(), f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [a, b]);
f.stack.execute(made);
f.stack.execute(new OrderVectorShapeCommand(f.assetId, a, 'front'));
expect(f.tree()).toEqual([`${made.groupId}[${b} ${a}] ${c}`]);
// Already on top of its group: the shape outside is no step further.
expect(() => f.stack.execute(new OrderVectorShapeCommand(f.assetId, a, 'forward'))).toThrow();
f.stack.execute(new OrderVectorShapeCommand(f.assetId, made.groupId, 'front'));
expect(f.tree()).toEqual([`${c} ${made.groupId}[${b} ${a}]`]);
});
it('duplicates a group with all that is in it, right over it, under new ids', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [a, b]);
f.stack.execute(made);
const copy = new DuplicateVectorShapeCommand(f.assetId, made.groupId);
f.stack.execute(copy);
const place = placeOf(f.art().layers, copy.shapeId)!;
expect(place.index).toBe(1);
expect(place.item.name).toBe('Group 1 copy');
const ids = allShapes(f.art()).map((s) => s.id);
expect(ids).toHaveLength(4);
expect(new Set(ids).size).toBe(4);
const copied = ids[2]!;
f.stack.undo();
f.stack.redo();
expect(allShapes(f.art())[2]!.id).toBe(copied);
});
it('duplicates a shape inside its group', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [a, b]);
f.stack.execute(made);
const copy = new DuplicateVectorShapeCommand(f.assetId, a);
f.stack.execute(copy);
expect(f.tree()).toEqual([`${made.groupId}[${a} ${copy.shapeId} ${b}]`]);
});
it('combines shapes across groups into the lowest and undoes it with the groups', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
const made = new GroupVectorShapesCommand(f.assetId, [b]);
f.stack.execute(made);
const combine = new CombineVectorShapesCommand(f.assetId, [a, b], 'union');
f.stack.execute(combine);
expect(combine.shapeId).toBe(a);
expect(f.tree()).toEqual([a]);
f.stack.undo();
expect(f.tree()).toEqual([`${a} ${made.groupId}[${b}]`]);
});
it('renames a group and refuses a blank name', () => {
const f = drawing();
const made = new GroupVectorShapesCommand(f.assetId, [f.add()]);
f.stack.execute(made);
f.stack.execute(new RenameVectorShapeCommand(f.assetId, made.groupId, ' Wheels '));
expect(placeOf(f.art().layers, made.groupId)?.item.name).toBe('Wheels');
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, made.groupId, ' '))).toThrow(
'A group needs a name',
);
f.stack.undo();
expect(placeOf(f.art().layers, made.groupId)?.item.name).toBe('Group 1');
});
it('moves shapes to another layer and back with an undo', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
const upper = new AddVectorLayerCommand(f.assetId);
f.stack.execute(upper);
f.stack.execute(new MoveVectorShapesCommand(f.assetId, [a], upper.layerId));
expect(f.tree()).toEqual([b, a]);
f.stack.undo();
expect(f.tree()).toEqual([`${a} ${b}`, '']);
});
it('adds a layer over the one named, reorders, renames and removes with all on it', () => {
const f = drawing();
const base = f.art().layers[0]!.id;
const top = new AddVectorLayerCommand(f.assetId);
f.stack.execute(top);
const between = new AddVectorLayerCommand(f.assetId, base);
f.stack.execute(between);
expect(f.art().layers.map((l) => l.id)).toEqual([base, between.layerId, top.layerId]);
f.stack.execute(new MoveVectorLayerCommand(f.assetId, 0, 2));
expect(f.art().layers.map((l) => l.id)).toEqual([between.layerId, top.layerId, base]);
f.stack.execute(new RenameVectorLayerCommand(f.assetId, base, 'Ink'));
expect(f.art().layers[2]!.name).toBe('Ink');
expect(() => f.stack.execute(new RenameVectorLayerCommand(f.assetId, base, ' '))).toThrow(
'A layer needs a name',
);
const a = f.add(base);
f.stack.execute(new RemoveVectorLayerCommand(f.assetId, base));
expect(allShapes(f.art())).toEqual([]);
f.stack.undo();
expect(allShapes(f.art()).map((s) => s.id)).toEqual([a]);
});
it('makes a new picture of a layer hidden, and the old one of it shown again', () => {
const f = drawing();
f.add();
const layerId = f.art().layers[0]!.id;
const shown = vectorArtRevision(f.art());
f.stack.execute(new SetVectorLayerHiddenCommand(f.assetId, layerId, true));
expect(f.art().layers[0]!.hidden).toBe(true);
expect(vectorArtRevision(f.art())).not.toBe(shown);
expect(vectorArtDrawings(f.art())).toEqual([]);
f.stack.execute(new SetVectorLayerHiddenCommand(f.assetId, layerId, false));
expect('hidden' in f.art().layers[0]!).toBe(false);
expect(vectorArtRevision(f.art())).toBe(shown);
});
it('draws the same picture whatever groups the shapes stand in', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
const flat = vectorArtRevision(f.art());
f.stack.execute(new GroupVectorShapesCommand(f.assetId, [a, b]));
expect(vectorArtRevision(f.art())).toBe(flat);
});
});
describe('the project file', () => {
it('gives layers, groups and the hidden mark back as they were written', () => {
const f = drawing();
const [a, b] = [f.add(), f.add()];
f.stack.execute(new GroupVectorShapesCommand(f.assetId, [a, b]));
const upper = new AddVectorLayerCommand(f.assetId);
f.stack.execute(upper);
f.add(upper.layerId);
f.stack.execute(new SetVectorLayerHiddenCommand(f.assetId, upper.layerId, true));
expect(f.art(roundTrip(f.project)).layers).toEqual(f.art().layers);
});
it('reads the flat shapes of an older drawing as its one layer', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
delete art.layers;
art.shapes = [shape('a'), shape('b')];
const loaded = f.art(roundTrip(f.project));
expect(loaded.layers).toHaveLength(1);
expect(loaded.layers[0]).toMatchObject({ name: 'Layer 1' });
expect(text(loaded.layers[0]!.items)).toBe('a b');
expect('shapes' in loaded).toBe(false);
});
it('drops an empty group, a doubled id anywhere in the drawing and a layer without an id', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
art.layers = [
{
id: 'l1',
name: 'One',
items: [shape('a'), { id: 'g', name: 'G', items: [] }, group('h', shape('a'))],
},
{ id: 'l1', name: 'Twin', items: [shape('b')] },
{ name: 'Nameless', items: [shape('c')] },
{ id: 'l2', hidden: 'yes', items: [shape('a'), shape('d')] },
];
const loaded = f.art(roundTrip(f.project));
expect(layersText(loaded.layers)).toEqual(['l1: a', 'l2: d']);
expect(loaded.layers[1]).toMatchObject({ name: 'Layer' });
expect('hidden' in loaded.layers[1]!).toBe(false);
});
it('gives a drawing left without a layer one to draw on', () => {
const f = drawing();
(f.art() as unknown as Record<string, unknown>).layers = [];
const loaded = f.art(roundTrip(f.project));
expect(loaded.layers).toHaveLength(1);
expect(loaded.layers[0]!.items).toEqual([]);
});
});
+43 -39
View File
@@ -3,7 +3,7 @@ import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/comm
import { import {
AddVectorShapeCommand, AddVectorShapeCommand,
DuplicateVectorShapeCommand, DuplicateVectorShapeCommand,
MoveVectorShapeCommand, OrderVectorShapeCommand,
RemoveVectorShapeCommand, RemoveVectorShapeCommand,
RenameVectorShapeCommand, RenameVectorShapeCommand,
SetVectorShapeRingsCommand, SetVectorShapeRingsCommand,
@@ -19,7 +19,6 @@ import {
isDrawableRing, isDrawableRing,
SHAPE_FILL_DEFAULT, SHAPE_FILL_DEFAULT,
shapeExtent, shapeExtent,
shapeOrderTarget,
VECTOR_CANVAS_DEFAULT, VECTOR_CANVAS_DEFAULT,
VECTOR_CANVAS_MAX, VECTOR_CANVAS_MAX,
VECTOR_HEIGHT_KEY, VECTOR_HEIGHT_KEY,
@@ -28,6 +27,7 @@ import {
vectorArtRevision, vectorArtRevision,
vectorCanvasSize, vectorCanvasSize,
} from '../../src/core/model/vectorart'; } from '../../src/core/model/vectorart';
import { allShapes, shapeOrderTarget } from '../../src/core/model/vectorTree';
import { makeProject, roundTrip } from '../helpers'; import { makeProject, roundTrip } from '../helpers';
/** A square, and the same square with one point bent into a curve. */ /** A square, and the same square with one point bent into a curve. */
@@ -60,7 +60,7 @@ describe('the vector art asset', () => {
it('starts empty on a canvas of its own', () => { it('starts empty on a canvas of its own', () => {
const f = drawing(); const f = drawing();
expect(f.art().shapes).toEqual([]); expect(allShapes(f.art())).toEqual([]);
expect(vectorCanvasSize(f.art().props)).toEqual({ expect(vectorCanvasSize(f.art().props)).toEqual({
width: VECTOR_CANVAS_DEFAULT, width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT, height: VECTOR_CANVAS_DEFAULT,
@@ -150,7 +150,9 @@ describe('the revision', () => {
type: 'vectorart', type: 'vectorart',
name: 'A', name: 'A',
props: {}, props: {},
shapes: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }], layers: [
{ id: 'l', name: 'Layer 1', items: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }] },
],
}); });
// The same ring with its two keys set the other way round – what a project file gives back. // The same ring with its two keys set the other way round – what a project file gives back.
const built = { closed: true, points: square().points }; const built = { closed: true, points: square().points };
@@ -203,14 +205,14 @@ describe('editing the shapes', () => {
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233); const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
f.stack.execute(first); f.stack.execute(first);
f.stack.execute(second); f.stack.execute(second);
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 }); expect(allShapes(f.art())[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT); expect(allShapes(f.art())[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
f.stack.undo(); f.stack.undo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']); expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1']);
f.stack.redo(); f.stack.redo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
}); });
it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => { it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => {
@@ -221,13 +223,13 @@ describe('editing the shapes', () => {
return add.shapeId; return add.shapeId;
}); });
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!)); f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!));
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[0], ids[2]]); expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[0], ids[2]]);
f.stack.undo(); f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(ids); expect(allShapes(f.art()).map((s) => s.id)).toEqual(ids);
// The last one may go: an empty drawing is a drawing. // The last one may go: an empty drawing is a drawing.
for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id)); for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id));
expect(f.art().shapes).toEqual([]); expect(allShapes(f.art())).toEqual([]);
}); });
it('duplicates a shape right above it, named as a copy, with outlines of its own', () => { it('duplicates a shape right above it, named as a copy, with outlines of its own', () => {
@@ -240,7 +242,7 @@ describe('editing the shapes', () => {
const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId); const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId);
f.stack.execute(copy); f.stack.execute(copy);
f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId)); f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId));
const shapes = f.art().shapes; const shapes = allShapes(f.art());
expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']); expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']);
expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 }); expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 });
expect(shapes[2]!.rings).toEqual(shapes[0]!.rings); expect(shapes[2]!.rings).toEqual(shapes[0]!.rings);
@@ -249,7 +251,7 @@ describe('editing the shapes', () => {
f.stack.undo(); f.stack.undo();
f.stack.undo(); f.stack.undo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/); expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/);
}); });
@@ -261,19 +263,19 @@ describe('editing the shapes', () => {
return add.shapeId; return add.shapeId;
}); });
// Last is on top, so "forward" is one step towards the end and "front" is the end. // Last is on top, so "forward" is one step towards the end and "front" is the end.
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'forward')).toBe(1); expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'forward')).toBe(1);
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'front')).toBe(2); expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'front')).toBe(2);
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1); expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'backward')).toBe(1);
expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0); expect(shapeOrderTarget(allShapes(f.art()), ids[1]!, 'back')).toBe(0);
// Already there, and not there at all. // Already there, and not there at all.
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1); expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'front')).toBe(-1);
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1); expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'back')).toBe(-1);
expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1); expect(shapeOrderTarget(allShapes(f.art()), 'nobody', 'front')).toBe(-1);
f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2)); f.stack.execute(new OrderVectorShapeCommand(f.assetId, ids[0]!, 'front'));
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]); expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
f.stack.undo(); f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(ids); expect(allShapes(f.art()).map((s) => s.id)).toEqual(ids);
}); });
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => { it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
@@ -283,15 +285,15 @@ describe('editing the shapes', () => {
const rings = [square()]; const rings = [square()];
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } }; rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings); const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings);
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, rings)).toBe(true); expect(SetVectorShapeRingsCommand.changes(allShapes(f.art())[0]!, rings)).toBe(true);
f.stack.execute(edit); f.stack.execute(edit);
rings[0]!.points[1] = { x: -999, y: -999 }; rings[0]!.points[1] = { x: -999, y: -999 };
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } }); expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
expect(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 }); expect(allShapes(f.art())[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
f.stack.undo(); f.stack.undo();
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 }); expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false); expect(SetVectorShapeRingsCommand.changes(allShapes(f.art())[0]!, [square()])).toBe(false);
}); });
it('writes fill, opacity and outline one field at a time and takes each back', () => { it('writes fill, opacity and outline one field at a time and takes each back', () => {
@@ -306,16 +308,16 @@ describe('editing the shapes', () => {
}), }),
); );
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot. // An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 }); expect(allShapes(f.art())[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 }); expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null })); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
expect(f.art().shapes[0]!.stroke).toBeUndefined(); expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
f.stack.undo(); f.stack.undo();
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 }); expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.undo(); f.stack.undo();
expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 }); expect(allShapes(f.art())[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(f.art().shapes[0]!.stroke).toBeUndefined(); expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
}); });
it('refuses an outline of no width and a shape of no name', () => { it('refuses an outline of no width and a shape of no name', () => {
@@ -364,12 +366,13 @@ describe('what a project file may say', () => {
stroke: { color: 0xffffff, width: 3 }, stroke: { color: 0xffffff, width: 3 },
}), }),
); );
expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes); expect(allShapes(f.art(roundTrip(f.project)))).toEqual(allShapes(f.art()));
}); });
it('drops what cannot be drawn from and keeps what someone is still drawing', () => { it('drops what cannot be drawn from and keeps what someone is still drawing', () => {
const f = drawing(); const f = drawing();
const art = f.art() as unknown as Record<string, unknown>; const art = f.art() as unknown as Record<string, unknown>;
delete art.layers;
art.shapes = [ art.shapes = [
{ {
id: 'one', id: 'one',
@@ -396,8 +399,8 @@ describe('what a project file may say', () => {
'not a shape', 'not a shape',
]; ];
const loaded = f.art(roundTrip(f.project)); const loaded = f.art(roundTrip(f.project));
expect(loaded.shapes).toHaveLength(1); expect(allShapes(loaded)).toHaveLength(1);
const shape = loaded.shapes[0]!; const shape = allShapes(loaded)[0]!;
// The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195); // The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195);
// the broken points fall away entirely. // the broken points fall away entirely.
expect(shape.rings[0]!.points).toEqual([ expect(shape.rings[0]!.points).toEqual([
@@ -414,8 +417,9 @@ describe('what a project file may say', () => {
it('falls back to a fill and a name where the file says nothing usable', () => { it('falls back to a fill and a name where the file says nothing usable', () => {
const f = drawing(); const f = drawing();
const art = f.art() as unknown as Record<string, unknown>; const art = f.art() as unknown as Record<string, unknown>;
delete art.layers;
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }]; art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
const shape = f.art(roundTrip(f.project)).shapes[0]!; const shape = allShapes(f.art(roundTrip(f.project)))[0]!;
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 }); expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(shape.rings).toEqual([{ closed: false, points: [] }]); expect(shape.rings).toEqual([{ closed: false, points: [] }]);
}); });
+3 -2
View File
@@ -30,6 +30,7 @@ import { EditorState } from '../../src/editor/state';
import { VStack } from '../../src/ui'; import { VStack } from '../../src/ui';
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render'; import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
import { PaintCache } from '../../src/viewport/vectorArtPicture'; 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. */ /** One frame of the render loop, the way the ticker runs it. */
function frames(): () => void { function frames(): () => void {
@@ -188,7 +189,7 @@ describe('dragging a colour stop of a gradient (#274)', () => {
const state = new EditorState(); const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1); state.loadProject(createEmptyProject('p', 'P'), 1);
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
asset.shapes.push({ asset.layers[0]!.items.push({
id: 's', id: 's',
name: 'Sky', name: 'Sky',
fill: 0x0000ff, fill: 0x0000ff,
@@ -219,7 +220,7 @@ describe('dragging a colour stop of a gradient (#274)', () => {
bar.resize(112, 32); bar.resize(112, 32);
const pointer = (type: string, x: number) => const pointer = (type: string, x: number) =>
bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined }); bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined });
const offsets = () => (asset.shapes[0]!.paint as VectorGradient).stops.map((stop) => stop.offset); const offsets = () => (allShapes(asset)[0]!.paint as VectorGradient).stops.map((stop) => stop.offset);
return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets }; return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets };
} }
+5 -4
View File
@@ -25,6 +25,7 @@ import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
import { resetRenderLoop } from '../../src/ui/render'; import { resetRenderLoop } from '../../src/ui/render';
import type { DrawItem } from '../../src/viewport/items'; import type { DrawItem } from '../../src/viewport/items';
import { PaintCache } from '../../src/viewport/vectorArtPicture'; 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. */ /** Presses run on one clock, far enough apart that no two join unless a test asks for a double-click. */
let clock = 0; let clock = 0;
@@ -207,7 +208,7 @@ describe('the vector art tab', () => {
{ x: 50, y: 50 }, { x: 50, y: 50 },
{ x: 10, y: 50 }, { x: 10, y: 50 },
]; ];
asset.shapes.push({ asset.layers[0]!.items.push({
id: 's', id: 's',
name: 'Square', name: 'Square',
fill: 0x0000ff, fill: 0x0000ff,
@@ -221,7 +222,7 @@ describe('the vector art tab', () => {
canvas.redraw(); canvas.redraw();
state.select(shapeSelection(asset.id, ['s'])); state.select(shapeSelection(asset.id, ['s']));
const at = (x: number, y: number) => canvas.imageToScreen(x, y); const at = (x: number, y: number) => canvas.imageToScreen(x, y);
const points = () => asset.shapes[0]?.rings[0]?.points ?? []; const points = () => allShapes(asset)[0]?.rings[0]?.points ?? [];
return { state, asset, canvas, at, points, ...gestures(canvas, asset) }; return { state, asset, canvas, at, points, ...gestures(canvas, asset) };
} }
@@ -257,13 +258,13 @@ describe('the vector art tab', () => {
expect(canvas.pickedKind).toBe('straight'); expect(canvas.pickedKind).toBe('straight');
expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(points()).toHaveLength(3); expect(points()).toHaveLength(3);
expect(asset.shapes).toHaveLength(1); expect(allShapes(asset)).toHaveLength(1);
state.undo(); state.undo();
expect(points()).toHaveLength(4); expect(points()).toHaveLength(4);
click(at(30, 30)); click(at(30, 30));
expect(canvas.pickedKind).toBe(null); expect(canvas.pickedKind).toBe(null);
expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(canvas.onKeyDown(key('Delete'))).toBe(true);
expect(asset.shapes).toHaveLength(0); expect(allShapes(asset)).toHaveLength(0);
}); });
}); });
+3 -2
View File
@@ -36,6 +36,7 @@ import {
} from '../../src/editor/inspector/shapeSection'; } from '../../src/editor/inspector/shapeSection';
import { EditorState, shapeSelection } from '../../src/editor/state'; import { EditorState, shapeSelection } from '../../src/editor/state';
import { PaintCache } from '../../src/viewport/vectorArtPicture'; import { PaintCache } from '../../src/viewport/vectorArtPicture';
import { allShapes } from '../../src/core/model/vectorTree';
/** /**
* These rows really are built, and a widget repaints itself one microtask after a value goes into * These rows really are built, and a widget repaints itself one microtask after a value goes into
@@ -85,7 +86,7 @@ function withShapes(count: number): { host: InspectorHost; assetId: string; ids:
} }
const shapeOf = (host: InspectorHost, assetId: string, index = 0) => const shapeOf = (host: InspectorHost, assetId: string, index = 0) =>
findAssetOf(host.state.project, assetId, 'vectorart')!.shapes[index]!; allShapes(findAssetOf(host.state.project, assetId, 'vectorart')!)[index]!;
function widgets<T extends Widget>(host: InspectorHost, type: new (...args: never[]) => T): T[] { function widgets<T extends Widget>(host: InspectorHost, type: new (...args: never[]) => T): T[] {
const found: T[] = []; const found: T[] = [];
@@ -239,7 +240,7 @@ describe('combining from the inspector (#187)', () => {
buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]); buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]);
expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.'); expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.');
button(host, 'Union').onClick!(); button(host, 'Union').onClick!();
expect(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.map((s) => s.id)).toEqual([ expect(allShapes(findAssetOf(host.state.project, assetId, 'vectorart')!).map((s) => s.id)).toEqual([
ids[0], ids[0],
ids[1], ids[1],
]); ]);
+435
View File
@@ -0,0 +1,435 @@
/**
* Layers and groups of vector art in the editor (#190): the two lists of the inspector, the buttons
* that group, and what a press in the tab does to a shape that stands in a group or on a hidden layer.
*/
import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import {
AddVectorLayerCommand,
GroupVectorShapesCommand,
SetVectorLayerHiddenCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorItem, VectorShape } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import type { Vec2 } from '../../src/core/model/Properties';
import { allShapes } from '../../src/core/model/vectorTree';
import type { InspectorHost } from '../../src/editor/inspector/host';
import {
buildShapeListSection,
buildShapeSection,
currentLayerList,
currentShapeList,
moveShapesToLayer,
pickShapeRow,
shapeListSignature,
shapeSignature,
} from '../../src/editor/inspector/shapeSection';
import { EditorState, shapeSelection } from '../../src/editor/state';
import { activeLayerOf, pickVectorLayer } from '../../src/editor/vectorart/activeLayer';
import { groupShapes, ungroupShapes } from '../../src/editor/vectorart/grouping';
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
import { Button, Label, 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());
/** 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 },
],
},
],
};
}
function text(items: readonly VectorItem[]): string {
return items.map((item) => ('items' in item ? `${item.id}[${text(item.items)}]` : item.id)).join(' ');
}
/** A drawing with the squares a, b and c side by side on its one layer. */
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', 0), square('b', 40), square('c', 80));
state.project.assets.push(asset);
const host: InspectorHost = {
state,
body: new VStack(),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
/** The rows of the current selection, built anew as the inspector does after a change. */
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);
else buildShapeListSection(host, asset);
};
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 button = (label: string) => widgets(Button).find((b) => b.text === label)!;
const texts = () => widgets(Label).map((label) => label.text);
const group = (...ids: string[]): string => {
const command = new GroupVectorShapesCommand(asset.id, ids);
state.execute(command);
return command.groupId;
};
const addLayer = (): string => {
const command = new AddVectorLayerCommand(asset.id);
state.execute(command);
return command.layerId;
};
const tree = () => asset.layers.map((layer) => text(layer.items));
return { state, asset, host, build, button, texts, group, addLayer, tree };
}
describe('the lists of the inspector', () => {
it('lists the layers top first and marks the one worked on', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
const upper = r.addLayer();
r.build();
expect(currentLayerList()!.shownIds).toEqual([upper, base]);
// Nothing was picked yet, so the topmost layer is the one worked on – and it is empty.
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
expect(currentShapeList()).toBeNull();
expect(r.texts()).toContain('Nothing on this layer yet – the Pen draws onto it.');
pickVectorLayer(r.state, r.asset.id, base);
r.build();
expect(currentShapeList()!.shownIds).toEqual(['c', 'b', 'a']);
});
it('follows the selected shape onto its layer, and remembers it once the shape is let go', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
r.addLayer();
r.state.select(shapeSelection(r.asset.id, ['b']));
expect(activeLayerOf(r.state, r.asset)?.id).toBe(base);
r.state.select({ kind: 'asset', assetId: r.asset.id });
expect(activeLayerOf(r.state, r.asset)?.id).toBe(base);
});
it('lets go of the shapes of another layer when a layer is picked', () => {
const r = rig();
const upper = r.addLayer();
r.state.select(shapeSelection(r.asset.id, ['b']));
pickVectorLayer(r.state, r.asset.id, upper);
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
});
it('rebuilds the rows for another layer, a group and a hidden layer', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
pickVectorLayer(r.state, r.asset.id, base);
const flat = shapeListSignature(r.asset);
r.group('a', 'b');
const grouped = shapeListSignature(r.asset);
expect(grouped).not.toBe(flat);
r.state.execute(new SetVectorLayerHiddenCommand(r.asset.id, base, true));
const hidden = shapeListSignature(r.asset);
expect(hidden).not.toBe(grouped);
const upper = r.addLayer();
pickVectorLayer(r.state, r.asset.id, base);
const onBase = shapeListSignature(r.asset);
pickVectorLayer(r.state, r.asset.id, upper);
expect(shapeListSignature(r.asset)).not.toBe(onBase);
expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).toContain(`@${base}[`);
});
it('shows a group over its shapes and selects all of them from its row', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a']));
r.build();
expect(currentShapeList()!.shownIds).toEqual(['c', g, 'b', 'a']);
pickShapeRow(r.state, r.asset.id, g, false);
expect(r.state.selectedShapeIds).toEqual(['a', 'b']);
});
it('adds a group to the selection with Ctrl and takes it out again as a whole', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['c']));
pickShapeRow(r.state, r.asset.id, g, true);
expect(r.state.selectedShapeIds).toEqual(['c', 'a', 'b']);
pickShapeRow(r.state, r.asset.id, g, true);
expect(r.state.selectedShapeIds).toEqual(['c']);
pickShapeRow(r.state, r.asset.id, 'c', true);
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
});
it('puts a shape added from the inspector onto the layer worked on', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
r.addLayer();
pickVectorLayer(r.state, r.asset.id, base);
r.build();
r.button('Add shape').onClick!();
expect(r.asset.layers[0]!.items).toHaveLength(4);
expect(r.asset.layers[1]!.items).toHaveLength(0);
});
});
describe('grouping from the inspector', () => {
it('groups the selection, which then reads as the group, and ungroups it again', () => {
const r = rig();
r.state.select(shapeSelection(r.asset.id, ['a', 'c']));
r.build();
expect(r.button('Group').enabled).toBe(true);
expect(r.button('Ungroup').enabled).toBe(false);
r.button('Group').onClick!();
const made = r.asset.layers[0]!.items[1]!;
expect(r.tree()).toEqual([`b ${made.id}[a c]`]);
expect(r.state.selectedShapeIds).toEqual(['a', 'c']);
r.build();
expect(r.button('Ungroup').enabled).toBe(true);
r.button('Ungroup').onClick!();
expect(r.tree()).toEqual(['b a c']);
r.state.undo();
r.state.undo();
expect(r.tree()).toEqual(['a b c']);
});
it('ungroups the group the primary shape stands in when no group is picked as a whole', () => {
const r = rig();
r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a']));
r.build();
expect(r.button('Ungroup').enabled).toBe(true);
r.button('Ungroup').onClick!();
expect(r.tree()).toEqual(['a b c']);
});
it('moves a whole picked group with the order buttons, and a lone shape inside its group', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
r.build();
r.button('To front').onClick!();
expect(r.tree()).toEqual([`c ${g}[a b]`]);
r.state.select(shapeSelection(r.asset.id, ['a']));
r.build();
expect(r.button('Backward').enabled).toBe(false);
r.button('Forward').onClick!();
expect(r.tree()).toEqual([`c ${g}[b a]`]);
});
it('switches Group off across two layers', () => {
const r = rig();
const upper = r.addLayer();
moveShapesToLayer(r.state, r.asset.id, ['c'], upper);
r.state.select(shapeSelection(r.asset.id, ['a', 'c']));
r.build();
expect(r.button('Group').enabled).toBe(false);
});
it('offers the Layer row from two layers on and moves a picked group as a whole', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
r.build();
expect(r.host.fields.has('shapeLayer')).toBe(false);
const upper = r.addLayer();
r.build();
expect(r.host.fields.has('shapeLayer')).toBe(true);
moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper);
expect(r.tree()).toEqual(['c', `${g}[a b]`]);
// The selection went along, so the layer worked on is now the upper one.
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
// Already there: no second step, so one undo takes the move back.
moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper);
r.state.undo();
expect(r.tree()).toEqual([`${g}[a b] c`, '']);
});
});
/** Presses run on one clock, far enough apart that no two join into a double-click. */
let clock = 0;
function pressAt(global: Vec2, held: 'ctrl' | 'shift' | null = null) {
clock += 1000;
return {
timeStamp: clock,
button: 0,
global,
altKey: false,
ctrlKey: held === 'ctrl',
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): void;
release(): void;
}
function tab() {
const r = rig();
const canvas = new VectorArtCanvas(r.state);
canvas.show(r.asset.id);
canvas.resize(400, 400);
canvas.redraw();
const target = canvas as unknown as Pressable;
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
// A Shift press only reaches the tab where it is not a pan, which the tab says itself.
const click = (point: Vec2, held: 'ctrl' | 'shift' | null = null) => {
if (held === 'shift' && !target.shiftPresses(r.asset, point)) return;
target.press(r.asset, pressAt(point, held), point);
target.release();
};
const drag = (from: Vec2, to: Vec2) => {
target.press(r.asset, pressAt(from), from);
target.move(to);
target.release();
};
const corner = (id: string) => allShapes(r.asset).find((s) => s.id === id)!.rings[0]!.points[0];
return { ...r, canvas, at, click, drag, corner };
}
describe('the tab', () => {
it('selects the whole group on a press on one of its shapes, the pressed one first', () => {
const t = tab();
t.group('a', 'b');
t.click(t.at(50, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] });
t.click(t.at(90, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['c'] });
});
it('selects the outermost group of a shape that stands two groups deep', () => {
const t = tab();
const inner = t.group('a');
t.group(inner, 'b');
t.click(t.at(10, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'a', shapeIds: ['a', 'b'] });
});
it('moves a group by dragging one of its shapes, as one undo step', () => {
const t = tab();
t.group('a', 'b');
t.drag(t.at(10, 10), t.at(15, 40));
expect(t.corner('a')).toMatchObject({ x: 5, y: 30 });
expect(t.corner('b')).toMatchObject({ x: 45, y: 30 });
expect(t.corner('c')).toMatchObject({ x: 80, y: 0 });
t.state.undo();
expect(t.corner('a')).toMatchObject({ x: 0, y: 0 });
expect(t.corner('b')).toMatchObject({ x: 40, y: 0 });
});
it('carries every selected shape when one of them is dragged, grouped or not', () => {
const t = tab();
t.state.select(shapeSelection(t.asset.id, ['a', 'c']));
t.drag(t.at(90, 10), t.at(90, 30));
expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['a', 'c'] });
expect(t.corner('a')).toMatchObject({ x: 0, y: 20 });
expect(t.corner('c')).toMatchObject({ x: 80, y: 20 });
expect(t.corner('b')).toMatchObject({ x: 40, y: 0 });
});
it.each(['ctrl', 'shift'] as const)(
'adds a shape to the selection with %s held and takes it out again',
(held) => {
const t = tab();
t.click(t.at(10, 10));
t.click(t.at(90, 10), held);
expect(t.state.selectedShapeIds).toEqual(['a', 'c']);
t.click(t.at(50, 10), held);
expect(t.state.selectedShapeIds).toEqual(['a', 'c', 'b']);
t.click(t.at(90, 10), held);
expect(t.state.selectedShapeIds).toEqual(['a', 'b']);
// Nothing was moved or changed on the way.
expect(t.corner('c')).toMatchObject({ x: 80, y: 0 });
},
);
it('leaves a Shift press to the pan beside the shapes and with another tool', () => {
const t = tab();
const target = t.canvas as unknown as Pressable;
expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(true);
expect(target.shiftPresses(t.asset, t.at(10, 60))).toBe(false);
t.canvas.setTool('pen');
expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(false);
});
it('leaves the selection as it is on a click that moves nothing', () => {
const t = tab();
t.group('a', 'b');
t.click(t.at(10, 10));
t.click(t.at(50, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] });
expect(t.corner('a')).toMatchObject({ x: 0, y: 0 });
// No step was made: the last one is still the grouping.
t.state.undo();
expect(t.tree()).toEqual(['a b c']);
});
it('groups and ungroups the selection, and does nothing while only the drawing is selected', () => {
const t = tab();
t.state.select(shapeSelection(t.asset.id, ['a', 'b']));
expect(groupShapes(t.state, t.asset.id)).toBe(true);
expect(t.tree()[0]).toMatch(/^\S+\[a b\] c$/);
expect(ungroupShapes(t.state, t.asset.id)).toBe(true);
expect(t.tree()).toEqual(['a b c']);
expect(ungroupShapes(t.state, t.asset.id)).toBe(false);
t.state.select({ kind: 'asset', assetId: t.asset.id });
expect(groupShapes(t.state, t.asset.id)).toBe(false);
});
it('does not let a shape on a hidden layer be pressed', () => {
const t = tab();
const base = t.asset.layers[0]!.id;
t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, base, true));
t.click(t.at(10, 10));
expect(t.state.selection).toEqual({ kind: 'asset', assetId: t.asset.id });
});
it('draws with the pen onto the layer worked on, and nothing onto a hidden one', () => {
const t = tab();
const base = t.asset.layers[0]!.id;
const upper = t.addLayer();
pickVectorLayer(t.state, t.asset.id, base);
t.canvas.setTool('pen');
t.click(t.at(150, 150));
expect(t.asset.layers[0]!.items).toHaveLength(4);
expect(t.asset.layers[1]!.items).toHaveLength(0);
t.canvas.onKeyDown({ key: 'Escape' } as KeyboardEvent);
t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, upper, true));
pickVectorLayer(t.state, t.asset.id, upper);
t.click(t.at(170, 170));
expect(allShapes(t.asset)).toHaveLength(4);
});
});
+169 -5
View File
@@ -100,7 +100,8 @@ export async function vectorArt(t) {
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
${MAKE_OBJECT} ${MAKE_OBJECT}
project.assets.push({ id: 'va-art', type: 'vectorart', name: 'Emblem', project.assets.push({ id: 'va-art', type: 'vectorart', name: 'Emblem',
props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} }, shapes: ${JSON.stringify(SHAPES)} }); props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} },
layers: [{ id: 'va-layer', name: 'Layer 1', items: ${JSON.stringify(SHAPES)} }] });
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate')); const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
sprite.id = 'va-sprite'; sprite.id = 'va-sprite';
sprite.name = 'Emblem sprite'; sprite.name = 'Emblem sprite';
@@ -163,7 +164,7 @@ export async function vectorArt(t) {
const before = picture?.file; const before = picture?.file;
await mutate(` await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === 'va-art'); const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
art.shapes[0].fill = 0x${REDRAWN.slice(1)}`); art.layers[0].items[0].fill = 0x${REDRAWN.slice(1)}`);
await settle(); await settle();
await sleep(300); await sleep(300);
const after = await json("__gm.drawnPicture('va-art')"); const after = await json("__gm.drawnPicture('va-art')");
@@ -745,7 +746,7 @@ export async function vectorArt(t) {
}); });
await mutate(` await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)}); const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art.shapes = ${JSON.stringify([square('va-left', 'Left', 0xcc3344, 40, 60), square('va-right', 'Right', 0x3344cc, 100, 100)])}; art.layers[0].items = ${JSON.stringify([square('va-left', 'Left', 0xcc3344, 40, 60), square('va-right', 'Right', 0x3344cc, 100, 100)])};
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`); __gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await settle(); await settle();
const selectedIds = () => json('__gm.vectorArt.selectedIds()'); const selectedIds = () => json('__gm.vectorArt.selectedIds()');
@@ -837,7 +838,7 @@ export async function vectorArt(t) {
}; };
await mutate(` await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)}); const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art.shapes = ${JSON.stringify([DISC, square('va-cover', 'Cover', 0x3344cc, 20, 20)])}; art.layers[0].items = ${JSON.stringify([DISC, square('va-cover', 'Cover', 0x3344cc, 20, 20)])};
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`); __gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await settle(); await settle();
const onDisc = await screenAt(170, 120); const onDisc = await screenAt(170, 120);
@@ -892,7 +893,7 @@ export async function vectorArt(t) {
}; };
await mutate(` await mutate(`
const artPaint = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)}); const artPaint = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
artPaint.shapes = ${JSON.stringify([SKY])}; artPaint.layers[0].items = ${JSON.stringify([SKY])};
__gm.state.select({ kind: 'shape', assetId: ${JSON.stringify(madeId)}, shapeId: 'va-sky', shapeIds: ['va-sky'] })`); __gm.state.select({ kind: 'shape', assetId: ${JSON.stringify(madeId)}, shapeId: 'va-sky', shapeIds: ['va-sky'] })`);
await evaluate(`__gm.openDocument('asset:${madeId}')`); await evaluate(`__gm.openDocument('asset:${madeId}')`);
await settle(); await settle();
@@ -1124,4 +1125,167 @@ export async function vectorArt(t) {
JSON.stringify({ stroke: linedPaint?.stroke, fillTo: linedPaint?.paint?.to }), JSON.stringify({ stroke: linedPaint?.stroke, fillTo: linedPaint?.paint?.to }),
); );
await shot('47-paint-outline'); await shot('47-paint-outline');
// 47j. Layers and groups (#190): three squares, grouped, moved as one, and a layer hidden.
const RED = '#cc3344';
const BLUE = '#3344cc';
const GREEN = '#33aa55';
await mutate(`
const art190 = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art190.layers = [{ id: 'va-base', name: 'Layer 1', items: ${JSON.stringify([
{ ...square('va-one', 'One', 0xcc3344, 10, 10), rings: [squareRing(10, 10, 60)] },
{ ...square('va-two', 'Two', 0x3344cc, 90, 10), rings: [squareRing(90, 10, 60)] },
{ ...square('va-three', 'Three', 0x33aa55, 170, 10), rings: [squareRing(170, 10, 60)] },
])} }];
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await evaluate(`__gm.openDocument('asset:${madeId}')`);
await clickText('Select');
await settle();
const layers190 = () => json(`__gm.vectorArt.layers(${JSON.stringify(madeId)})`);
const rowIds = (layer) => layer.rows.map((r) => `${r.id}:${r.depth}`).join(' ');
const cornerOf = async (id) =>
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).find((s) => s.id === id)?.rings[0]
.points[0];
const rowCentre = async (id) => {
const r = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(id)})`);
return { x: r.x + r.w / 2, y: r.y + r.h / 2, h: r.h };
};
// Two shapes picked in the tab, then the Group button: one group where the upper one stood.
// The second is added with Shift, which does what Ctrl does in 47g.
const onOne = await screenAt(40, 40);
const onTwo = await screenAt(120, 40);
await click(onOne.x, onOne.y);
await click(onTwo.x, onTwo.y, 'left', { shift: true });
await settle();
await clickText('Group');
await settle();
const grouped = (await layers190())[0];
const groupId = grouped.rows.find((r) => r.group)?.id;
const nameX = async (id) => (await json(`__gm.vectorArt.shapeNameAt(${JSON.stringify(id)})`)).x;
const indent = (await nameX('va-two')) - (await nameX('va-three'));
check(
'Group puts the two selected shapes into a group, its rows one step in',
rowIds(grouped) === `va-three:0 ${groupId}:0 va-two:1 va-one:1` &&
(await nameX(groupId)) === (await nameX('va-three')) &&
indent === 14 &&
(await json('__gm.vectorArt.selectedIds()')).join() === 'va-one,va-two',
JSON.stringify({ rows: grouped.rows, indent, selected: await json('__gm.vectorArt.selectedIds()') }),
);
await shot('47-groups');
// The keys do the same from wherever the focus is: Ctrl+Shift+G takes the group away, Ctrl+G
// puts the same two shapes into a new one.
await press('g', { ctrl: true, shift: true });
await settle();
const ungrouped = rowIds((await layers190())[0]);
await press('g', { ctrl: true });
await settle();
const regrouped = (await layers190())[0];
check(
'Ctrl+Shift+G takes the group away and Ctrl+G groups the selection again',
ungrouped === 'va-three:0 va-two:0 va-one:0' &&
regrouped.rows.map((r) => `${r.name}:${r.depth}`).join(' ') === 'Three:0 Group 1:0 Two:1 One:1',
JSON.stringify({ ungrouped, regrouped: regrouped.rows }),
);
await undo();
await undo();
await settle();
// A press on one shape of the group takes the whole group, and a drag moves both in one step.
const onThree = await screenAt(200, 40);
await click(onThree.x, onThree.y);
await settle();
const alone = await json('__gm.vectorArt.selectedIds()');
const lower = await screenAt(40, 140);
await drag(onOne.x, onOne.y, lower.x, lower.y, { steps: 8 });
await settle();
const movedOne = await cornerOf('va-one');
const movedTwo = await cornerOf('va-two');
const tookGroup = await json('__gm.vectorArt.selectedIds()');
const redBelow = await tabPixel(40, 140);
const blueBelow = await tabPixel(120, 140);
await undo();
await settle();
const backOne = await cornerOf('va-one');
const backTwo = await cornerOf('va-two');
check(
'dragging one shape of a group moves the whole group, and one undo puts both back',
alone.join() === 'va-three' &&
tookGroup.join() === 'va-one,va-two' &&
near(movedOne.y, 110) &&
near(movedTwo.y, 110) &&
near(movedTwo.x, 90) &&
colourDistance(redBelow, RED) < 14 &&
colourDistance(blueBelow, BLUE) < 14 &&
near(backOne.y, 10) &&
near(backTwo.y, 10),
JSON.stringify({ alone, tookGroup, movedOne, movedTwo, redBelow, blueBelow, backOne, backTwo }),
);
// A row dragged under the group's last row: straight down it joins the group, the row's own
// depth being 0 it stays outside – so it is dragged one indent to the right as well.
const threeRow = await rowCentre('va-three');
const oneRow = await rowCentre('va-one');
await drag(threeRow.x, threeRow.y, threeRow.x + 14, oneRow.y + oneRow.h / 2 - 2, { steps: 8 });
await settle();
const joined = (await layers190())[0];
check(
'a row dragged to the end of a group and one indent right joins the group',
rowIds(joined) === `${groupId}:0 va-two:1 va-one:1 va-three:1`,
JSON.stringify(joined.rows),
);
await undo();
await settle();
// The plus of the layer list: a second layer over the first, which the pen then draws onto.
const plus190 = await json('__gm.vectorArt.layerAddAt()');
await click(plus190.x, plus190.y);
await settle();
const twoLayers = await layers190();
const upperId = twoLayers[1]?.id;
check(
'the plus adds a layer on top, and it is the one worked on',
twoLayers.length === 2 &&
twoLayers[1].active &&
twoLayers[1].rows.length === 0 &&
(await json('__gm.vectorArt.layerRows()')).join() === `${upperId},va-base`,
JSON.stringify(twoLayers.map((l) => ({ id: l.id, active: l.active, rows: l.rows.length }))),
);
// The eye of the lower layer: its shapes leave the tab, and a press there finds nothing.
const eye = await json("__gm.vectorArt.layerEyeAt('va-base')");
await click(eye.x, eye.y);
await settle();
const hiddenRed = await tabPixel(40, 40);
await click(onOne.x, onOne.y);
await settle();
const pressedHidden = await json('__gm.vectorArt.selectedIds()');
await shot('47-layer-hidden');
const eyeAgain = await json("__gm.vectorArt.layerEyeAt('va-base')");
await click(eyeAgain.x, eyeAgain.y);
await settle();
const shownRed = await tabPixel(40, 40);
check(
'the eye hides a layer in the tab, its shapes cannot be pressed, and the eye shows it again',
colourDistance(hiddenRed, RED) > 60 &&
pressedHidden.length === 0 &&
(await layers190())[0].hidden === false &&
colourDistance(shownRed, RED) < 14 &&
colourDistance(await tabPixel(200, 40), GREEN) < 14,
JSON.stringify({ hiddenRed, pressedHidden, shownRed }),
);
}
/** A square ring of `size` pixels with its corner at `x`, `y`. */
function squareRing(x, y, size) {
return {
closed: true,
points: [
{ x, y },
{ x: x + size, y },
{ x: x + size, y: y + size },
{ x, y: y + size },
],
};
} }