Put the shapes of a vector drawing on layers and in groups (#190)
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:
1 parent
393caadc9d
commit
2ff3a78607
30 files changed
+2625
-408
No files matched your search
@@ -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:
|
||||
|
||||
```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
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -63,9 +63,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'rigidbody' };
|
||||
case 'container':
|
||||
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':
|
||||
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.
|
||||
case 'pixelart':
|
||||
return {
|
||||
|
||||
@@ -12,10 +12,12 @@ import type {
|
||||
VariableOwnerAsset,
|
||||
VariablesAsset,
|
||||
VectorArtAsset,
|
||||
VectorItem,
|
||||
VectorShape,
|
||||
} from '../model/Asset';
|
||||
import { findLayer } from '../model/tilemap';
|
||||
import { findShape } from '../model/vectorart';
|
||||
import { findVectorItem } from '../model/vectorTree';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
|
||||
import {
|
||||
@@ -126,6 +128,13 @@ export function requireVectorShape(project: Project, assetId: string, shapeId: s
|
||||
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 {
|
||||
const asset = findAssetOf(project, assetId, 'curve');
|
||||
if (!asset) throw new CommandError(`Curve ${assetId} not found`);
|
||||
|
||||
@@ -9,124 +9,179 @@
|
||||
* `model/vectorart.ts`), so a command that edits a shape cannot forget to say that it did.
|
||||
*/
|
||||
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 { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
|
||||
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
|
||||
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 { type Command, CommandError, MoveCommand } from './Command';
|
||||
import { type Command, CommandError } from './Command';
|
||||
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 {
|
||||
readonly label = 'Add shape';
|
||||
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(
|
||||
private readonly assetId: string,
|
||||
private readonly rings: readonly VectorRing[] = [],
|
||||
private readonly fill = SHAPE_FILL_DEFAULT,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireVectorArt(project, this.assetId);
|
||||
const name = nextNumberedName(
|
||||
asset.shapes.map((shape) => shape.name),
|
||||
'Shape',
|
||||
);
|
||||
asset.shapes.push({
|
||||
id: this.shapeId,
|
||||
name,
|
||||
rings: cloneRings(this.rings),
|
||||
fill: this.fill,
|
||||
opacity: 1,
|
||||
/**
|
||||
* `rings` are what the pen tool drew; a shape added from the list starts without a ring. It lands
|
||||
* on top of the layer `layerId`, and of the topmost layer without one.
|
||||
*/
|
||||
constructor(assetId: string, rings: readonly VectorRing[] = [], fill = SHAPE_FILL_DEFAULT, layerId = '') {
|
||||
let shape: VectorShape | null = null;
|
||||
this.tree = new EditVectorTreeCommand(assetId, this.label, (layers) => {
|
||||
shape ??= {
|
||||
id: this.shapeId,
|
||||
name: nextNumberedName(namesIn(layers, false), 'Shape'),
|
||||
rings: cloneRings(rings),
|
||||
fill,
|
||||
opacity: 1,
|
||||
};
|
||||
return withItemAdded(layers, layerId || topLayerId(layers), shape);
|
||||
});
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.tree.execute(project);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireVectorArt(project, this.assetId);
|
||||
asset.shapes = removeById(asset.shapes, this.shapeId);
|
||||
this.tree.undo(project);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
readonly label = 'Duplicate shape';
|
||||
readonly label: string = 'Duplicate shape';
|
||||
readonly shapeId = newId();
|
||||
private readonly tree: EditVectorTreeCommand;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly sourceId: string,
|
||||
) {}
|
||||
constructor(assetId: string, sourceId: string) {
|
||||
let copy: VectorItem | null = null;
|
||||
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 {
|
||||
const asset = requireVectorArt(project, this.assetId);
|
||||
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);
|
||||
this.tree.execute(project);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireVectorArt(project, this.assetId);
|
||||
asset.shapes = removeById(asset.shapes, this.shapeId);
|
||||
this.tree.undo(project);
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes a shape out; unlike a tilemap layer the last one may go – an empty drawing is a drawing. */
|
||||
export class RemoveVectorShapeCommand implements Command {
|
||||
readonly label = 'Remove shape';
|
||||
private removed: Removed<VectorShape> | undefined;
|
||||
/** A deep copy under new ids; the caller names the copy itself. */
|
||||
function copyOf(item: VectorItem): VectorItem {
|
||||
if (isVectorGroup(item)) return { id: newId(), name: item.name, items: item.items.map(copyOf) };
|
||||
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,
|
||||
private readonly shapeId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireVectorArt(project, this.assetId);
|
||||
const removed = removeFirst(asset.shapes, (shape) => shape.id === this.shapeId);
|
||||
if (!removed) throw new CommandError(`Shape ${this.shapeId} not found`);
|
||||
this.removed = removed;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (this.removed) restoreAt(requireVectorArt(project, this.assetId).shapes, [this.removed]);
|
||||
/**
|
||||
* Takes a shape out, or a group with all that is in it. Unlike a tilemap layer the last one may go –
|
||||
* an empty drawing is a drawing.
|
||||
*/
|
||||
export class RemoveVectorShapeCommand extends EditVectorTreeCommand {
|
||||
constructor(assetId: string, shapeId: string) {
|
||||
super(assetId, 'Remove shape', (layers) => {
|
||||
if (!placeOf(layers, shapeId)) throw new VectorTreeError(`Shape ${shapeId} not found`);
|
||||
return withItemsRemoved(layers, [shapeId]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes several shapes out in one step – Delete on a selection of more than one (#187). */
|
||||
export class RemoveVectorShapesCommand implements Command {
|
||||
readonly label = 'Remove shapes';
|
||||
private removed: Removed<VectorShape>[] = [];
|
||||
|
||||
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);
|
||||
export class RemoveVectorShapesCommand extends EditVectorTreeCommand {
|
||||
constructor(assetId: string, shapeIds: readonly string[]) {
|
||||
super(assetId, 'Remove shapes', (layers) => withItemsRemoved(layers, shapeIds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +206,7 @@ const NOTHING_LEFT: Record<ShapeBoolean, string> = {
|
||||
*/
|
||||
export class CombineVectorShapesCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed: Removed<VectorShape>[] = [];
|
||||
private previousLayers: VectorLayer[] = [];
|
||||
private previous: VectorRing[] = [];
|
||||
private resultId = '';
|
||||
|
||||
@@ -170,26 +225,29 @@ export class CombineVectorShapesCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
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');
|
||||
const blocked = combineBlocker(shapes);
|
||||
if (blocked) throw new CommandError(blocked);
|
||||
const rings = combinedRings(shapes, this.op);
|
||||
if (rings.length === 0) throw new CommandError(NOTHING_LEFT[this.op]);
|
||||
const base = shapes[0]!;
|
||||
const others = shapes.slice(1).map((shape) => shape.id);
|
||||
this.resultId = base.id;
|
||||
this.previous = base.rings;
|
||||
this.previousLayers = asset.layers;
|
||||
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 {
|
||||
const asset = requireVectorArt(project, this.assetId);
|
||||
restoreAt(asset.shapes, this.removed);
|
||||
asset.layers = this.previousLayers;
|
||||
requireVectorShape(project, this.assetId, this.resultId).rings = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames a shape or a group. */
|
||||
export class RenameVectorShapeCommand implements Command {
|
||||
readonly label = 'Rename shape';
|
||||
private previous = '';
|
||||
@@ -201,31 +259,113 @@ export class RenameVectorShapeCommand implements Command {
|
||||
) {}
|
||||
|
||||
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();
|
||||
if (!name) throw new CommandError('A shape needs a name');
|
||||
this.previous = shape.name;
|
||||
shape.name = name;
|
||||
if (!name) throw new CommandError(`A ${isVectorGroup(item) ? 'group' : 'shape'} needs a name`);
|
||||
this.previous = item.name;
|
||||
item.name = name;
|
||||
}
|
||||
|
||||
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. */
|
||||
export class MoveVectorShapeCommand extends MoveCommand {
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
from: number,
|
||||
to: number,
|
||||
) {
|
||||
super('Reorder shapes', from, to);
|
||||
/** One of the four order buttons: a step, or all the way, among the items of the same list. */
|
||||
export class OrderVectorShapeCommand extends EditVectorTreeCommand {
|
||||
constructor(assetId: string, shapeId: string, move: ShapeOrderMove) {
|
||||
super(assetId, 'Reorder shapes', (layers) => {
|
||||
const next = withItemOrdered(layers, shapeId, move);
|
||||
if (!next) throw new VectorTreeError('It is there already');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected move(project: Project, from: number, to: number): void {
|
||||
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to))
|
||||
throw new CommandError('No such shape');
|
||||
/**
|
||||
* Moves shapes and groups to another place in the tree (#190): into a group, out of one, onto
|
||||
* 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
@@ -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
|
||||
* 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 {
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { PropertyBag, Vec2 } from './Properties';
|
||||
import type { TextureRef } from './texture';
|
||||
import { isDrawableRing, vectorCanvasSize } from './vectorart';
|
||||
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. */
|
||||
export type PathOp =
|
||||
@@ -59,10 +60,10 @@ export interface ShapeDrawing {
|
||||
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[] {
|
||||
const drawings: ShapeDrawing[] = [];
|
||||
for (const shape of asset.shapes) {
|
||||
for (const shape of drawnShapes(asset)) {
|
||||
const drawing = shapeDrawing(shape, project);
|
||||
if (drawing) drawings.push(drawing);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { fingerprint, type GeneratedPicture } from './generated';
|
||||
import type { Project } from './Project';
|
||||
import type { PropertyBag, Vec2 } from './Properties';
|
||||
import { paintText, textureFillText } from './vectorPaint';
|
||||
import { allShapes, drawnShapes } from './vectorTree';
|
||||
|
||||
export const VECTOR_WIDTH_KEY = 'canvasWidth';
|
||||
export const VECTOR_HEIGHT_KEY = 'canvasHeight';
|
||||
@@ -33,7 +34,7 @@ function canvasSide(value: unknown): number {
|
||||
}
|
||||
|
||||
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,
|
||||
): string {
|
||||
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('|')}`);
|
||||
}
|
||||
|
||||
@@ -135,29 +137,3 @@ export function cloneVectorPoint(point: VectorPoint): VectorPoint {
|
||||
...(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
@@ -38,6 +38,8 @@ import type {
|
||||
VectorPaint,
|
||||
VectorPoint,
|
||||
VectorRing,
|
||||
VectorItem,
|
||||
VectorLayer,
|
||||
VectorShape,
|
||||
} from '../model/Asset';
|
||||
import { parseCellKey } from '../model/tilemap';
|
||||
@@ -236,7 +238,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
} else if (type === 'tilemap') {
|
||||
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
|
||||
} 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') {
|
||||
assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) });
|
||||
} 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
|
||||
* 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
|
||||
* over it. A doubled shape id is dropped as a doubled layer is.
|
||||
* over it.
|
||||
*/
|
||||
function normalizeShapes(raw: unknown): VectorShape[] {
|
||||
const shapes: VectorShape[] = [];
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const s = asRecord(item);
|
||||
if (typeof s.id !== 'string' || !s.id || shapes.some((o) => o.id === s.id)) continue;
|
||||
const rings: VectorRing[] = [];
|
||||
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);
|
||||
function normalizeShape(s: Record<string, unknown>, id: string): VectorShape {
|
||||
const rings: VectorRing[] = [];
|
||||
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 });
|
||||
}
|
||||
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[] {
|
||||
const points: VectorPoint[] = [];
|
||||
for (const p of Array.isArray(raw) ? raw : []) {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
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';
|
||||
|
||||
/** The Vector art tab (#129). */
|
||||
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 {
|
||||
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
|
||||
vectorArt: {
|
||||
@@ -27,11 +40,11 @@ export function vectorArtHooks({ state, editor }: HookContext) {
|
||||
enabled: vectorArtEditor.pointButton.enabled,
|
||||
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) => {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset || asset.type !== 'vectorart') return null;
|
||||
return asset.shapes.map((shape) => ({
|
||||
return allShapes(asset).map((shape) => ({
|
||||
id: shape.id,
|
||||
name: shape.name,
|
||||
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 : ''),
|
||||
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.
|
||||
*/
|
||||
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. */
|
||||
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. */
|
||||
shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null,
|
||||
shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null,
|
||||
shapeDuplicateAt: (shapeId: string) => shapeList()?.duplicateGlobal(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. */
|
||||
shapeRenaming: () => {
|
||||
const field = currentShapeList()?.renameField;
|
||||
|
||||
@@ -9,31 +9,42 @@
|
||||
* rows, and only a rebuild puts the new list on screen.
|
||||
*/
|
||||
import {
|
||||
AddVectorLayerCommand,
|
||||
AddVectorShapeCommand,
|
||||
CombineVectorShapesCommand,
|
||||
DuplicateVectorShapeCommand,
|
||||
MoveVectorShapeCommand,
|
||||
MoveVectorLayerCommand,
|
||||
MoveVectorShapesCommand,
|
||||
OrderVectorShapeCommand,
|
||||
RemoveVectorLayerCommand,
|
||||
RemoveVectorShapeCommand,
|
||||
RenameVectorLayerCommand,
|
||||
RenameVectorShapeCommand,
|
||||
SetVectorLayerHiddenCommand,
|
||||
SetVectorShapeStyleCommand,
|
||||
} from '../../core/commands/vectorArtCommands';
|
||||
import type {
|
||||
VectorArtAsset,
|
||||
VectorGradient,
|
||||
VectorItem,
|
||||
VectorPaint,
|
||||
VectorShape,
|
||||
VectorStroke,
|
||||
} from '../../core/model/Asset';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { findShape, SHAPE_FILL_DEFAULT, shapeExtent, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import {
|
||||
findShape,
|
||||
SHAPE_FILL_DEFAULT,
|
||||
shapeExtent,
|
||||
allShapes,
|
||||
canOrderItem,
|
||||
findVectorItem,
|
||||
isVectorGroup,
|
||||
placeOf,
|
||||
selectedItems,
|
||||
type ShapeOrderMove,
|
||||
shapeOrderTarget,
|
||||
vectorCanvasSize,
|
||||
} from '../../core/model/vectorart';
|
||||
shapesIn,
|
||||
treeRows,
|
||||
} from '../../core/model/vectorTree';
|
||||
import { combineBlocker, type ShapeBoolean } from '../../core/model/vectorBoolean';
|
||||
import {
|
||||
FILL_KINDS,
|
||||
@@ -50,10 +61,13 @@ import {
|
||||
withStopChanged,
|
||||
} from '../../core/model/vectorPaint';
|
||||
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 { FramePreview } from '../framePreview';
|
||||
import { listFold } from '../sectionFolds';
|
||||
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 { ShapeList } from '../vectorart/ShapeList';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
@@ -174,6 +188,9 @@ const COMBINE_BUTTONS: { op: ShapeBoolean; label: string }[] = [
|
||||
{ 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 OPACITY_DEF: PropertyDef = {
|
||||
key: 'opacity',
|
||||
@@ -202,21 +219,33 @@ const STROKE_WIDTH_DEF: PropertyDef = {
|
||||
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 builtLayers: SortableList | null = null;
|
||||
|
||||
/** Null while no vector art shapes are on screen. */
|
||||
export function currentShapeList(): ShapeList | null {
|
||||
return built;
|
||||
}
|
||||
|
||||
/** What the shapes of an asset make of the rows: their order, their names, and who is drawn. */
|
||||
export function shapeListSignature(asset: VectorArtAsset): string {
|
||||
return asset.shapes.map((shape) => `${shape.id}:${shape.name}`).join(',');
|
||||
/** Null while no vector art is in the inspector. */
|
||||
export function currentLayerList(): SortableList | null {
|
||||
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
|
||||
* 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';
|
||||
const blocked = combineBlocker(selectedShapes(asset, shapeIds)) ?? '';
|
||||
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. */
|
||||
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. */
|
||||
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');
|
||||
addGap(host);
|
||||
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
|
||||
* from, and the buttons that combine all selected shapes into one (#187).
|
||||
* 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) or group them (#190).
|
||||
*/
|
||||
export function buildShapeSection(
|
||||
host: InspectorHost,
|
||||
@@ -261,28 +291,34 @@ export function buildShapeSection(
|
||||
const shape = asset && findShape(asset, shapeId);
|
||||
if (!asset || !shape) return;
|
||||
|
||||
addShapeList(host, asset, shapeIds);
|
||||
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
|
||||
addLayerList(host, asset, shapeId);
|
||||
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.
|
||||
for (let start = 0; start < ORDER_BUTTONS.length; start += 2) {
|
||||
const order = new HStack({ gap: Theme.gap });
|
||||
for (const { move, label } of ORDER_BUTTONS.slice(start, start + 2)) {
|
||||
const button = new Button(label, {
|
||||
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.
|
||||
button.setEnabled(shapeOrderTarget(asset.shapes, shapeId, move) >= 0);
|
||||
// A shape already at the front of its list has nowhere to go forwards.
|
||||
button.setEnabled(canOrderItem(asset.layers, ordered, move));
|
||||
order.add(button, 1);
|
||||
}
|
||||
host.body.add(order);
|
||||
}
|
||||
|
||||
addGroupButtons(host, asset, shapeId, shapeIds);
|
||||
addCombineButtons(host, asset, shapeIds);
|
||||
|
||||
addTitle(host, 'VECTOR SHAPE');
|
||||
addShapeName(host, assetId, shape);
|
||||
addLayerRow(host, asset, shapeId, shapeIds);
|
||||
fillRows(host, assetId, shape);
|
||||
styleRow(
|
||||
host,
|
||||
@@ -330,6 +366,67 @@ export function buildShapeSection(
|
||||
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
|
||||
* 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);
|
||||
if (!shape) return false;
|
||||
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_TYPE_DEF.key)?.setValue(fillKind(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;
|
||||
}
|
||||
|
||||
function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: readonly string[]): void {
|
||||
if (asset.shapes.length === 0) {
|
||||
/** The layers of the drawing, the top one drawn last; the marked one is the one being worked on. */
|
||||
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;
|
||||
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;
|
||||
}
|
||||
const list = new ShapeList(
|
||||
host.state,
|
||||
asset.id,
|
||||
asset.shapes.map((shape) => shape.id),
|
||||
selectedIds,
|
||||
{
|
||||
onPick: (shapeId, toggle) =>
|
||||
toggle
|
||||
? 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)),
|
||||
const { state } = host;
|
||||
const list = new ShapeList(state, asset.id, rows, markedRows(asset, selectedIds), {
|
||||
onPick: (itemId, toggle) => pickShapeRow(state, asset.id, itemId, toggle),
|
||||
onDrop: (itemId, drop) =>
|
||||
void state.execute(new MoveVectorShapesCommand(asset.id, [itemId], layer.id, drop)),
|
||||
onDuplicate: (itemId) => {
|
||||
const command = new DuplicateVectorShapeCommand(asset.id, itemId);
|
||||
if (state.execute(command)) pickShapeRow(state, asset.id, command.shapeId, false);
|
||||
},
|
||||
);
|
||||
onRemove: (itemId) => removeShape(host, asset.id, itemId),
|
||||
onRename: (itemId, name) => void state.execute(new RenameVectorShapeCommand(asset.id, itemId, name)),
|
||||
});
|
||||
built = list;
|
||||
host.body.add(list);
|
||||
}
|
||||
|
||||
/** The cross on a row: the shape goes, and with it out of the selection – the rest stays selected. */
|
||||
function removeShape(host: InspectorHost, assetId: string, shapeId: string): void {
|
||||
const rest = host.state.selectedShapeIds.filter((id) => id !== shapeId);
|
||||
/** The selected shapes, and every group all of whose shapes are selected. */
|
||||
function markedRows(asset: VectorArtAsset, shapeIds: readonly string[]): string[] {
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
|
||||
function addShapeButton(host: InspectorHost, assetId: string): Button {
|
||||
return new Button('Add shape', {
|
||||
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]));
|
||||
},
|
||||
});
|
||||
@@ -441,15 +621,6 @@ function addShapeName(host: InspectorHost, assetId: string, shape: VectorShape):
|
||||
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. */
|
||||
function outlineRow(host: InspectorHost, assetId: string, shapeId: string, shape: VectorShape): void {
|
||||
const row = createFieldRow(host, OUTLINE_DEF, Boolean(shape.stroke), {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ui } from '../ui';
|
||||
import { createContainer, removeSelection } from './actions';
|
||||
import type { EditorState } from './state';
|
||||
import { groupShapes, ungroupShapes } from './vectorart/grouping';
|
||||
|
||||
export interface ShortcutActions {
|
||||
play(): void;
|
||||
@@ -52,6 +53,8 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
|
||||
if (mod && key === 'g') {
|
||||
const s = state.selection;
|
||||
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;
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace')
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@ import { CommandStack } from '../core/commands/CommandStack';
|
||||
import type { Asset, SceneAsset } from '../core/model/Asset';
|
||||
import { findAnnotation, type Graph } from '../core/graph';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { allShapes } from '../core/model/vectorTree';
|
||||
import { resolvedHolder } from '../core/model/instance';
|
||||
import {
|
||||
createEmptyProject,
|
||||
@@ -719,7 +720,8 @@ export class EditorState {
|
||||
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
|
||||
if (!asset) this.select(null);
|
||||
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)
|
||||
this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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.
|
||||
*/
|
||||
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. */
|
||||
protected abstract move(local: Vec2): 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. */
|
||||
protected context(_target: A, _e: FederatedPointerEvent, _local: Vec2): void {}
|
||||
|
||||
@@ -101,7 +108,8 @@ export abstract class SheetCanvas<A> extends Widget {
|
||||
const target = this.pressTarget();
|
||||
if (!target) return;
|
||||
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 };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
|
||||
@@ -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
|
||||
* inspector, over the rows that colour the selected shape – a panel of its own is #191.
|
||||
* The shapes of one layer of a vector art asset as a list with a preview each (#129, stage 4). It
|
||||
* 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
|
||||
* 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
|
||||
* 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 { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
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 { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
import { SortableList, type ThumbClip, type Widget } from '../../ui';
|
||||
@@ -27,39 +38,49 @@ export class ShapeList extends SortableList {
|
||||
private readonly stopPictures: () => void;
|
||||
|
||||
/**
|
||||
* `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with
|
||||
* Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onDuplicate` and
|
||||
* `tree` is the layer's tree as rows, top first, and `selectedIds` the rows marked. `onPick` is told
|
||||
* 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
|
||||
* plus: a shape is drawn with the Pen, and "Add shape" stays below.
|
||||
*/
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly assetId: string,
|
||||
shapeIds: readonly string[],
|
||||
tree: readonly TreeRow[],
|
||||
selectedIds: readonly string[],
|
||||
rows: {
|
||||
onPick: (shapeId: string, toggle: boolean) => void;
|
||||
onReorder: (from: number, to: number) => void;
|
||||
onDuplicate: (shapeId: string) => void;
|
||||
onRemove: (shapeId: string) => void;
|
||||
onRename: (shapeId: string, name: string) => void;
|
||||
onPick: (itemId: string, toggle: boolean) => void;
|
||||
onDrop: (itemId: string, drop: TreeDrop) => void;
|
||||
onDuplicate: (itemId: string) => void;
|
||||
onRemove: (itemId: string) => void;
|
||||
onRename: (itemId: string, name: string) => void;
|
||||
},
|
||||
) {
|
||||
super({
|
||||
title: 'Shapes',
|
||||
...listFold('inspector', 'Shapes'),
|
||||
reversed: true,
|
||||
multiSelect: true,
|
||||
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,
|
||||
onRemove: rows.onRemove,
|
||||
onRename: rows.onRename,
|
||||
thumb: (id, art, size) => this.paintSwatch(id, art, size),
|
||||
});
|
||||
const asset = this.asset();
|
||||
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,
|
||||
);
|
||||
// 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
|
||||
* 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 shape = asset?.shapes.find((s) => s.id === shapeId);
|
||||
if (!asset || !shape) return;
|
||||
const item = asset && findVectorItem(asset, itemId);
|
||||
if (!asset || !item) return;
|
||||
const { width, height } = vectorCanvasSize(asset.props);
|
||||
const scale = Math.min(size / width, size / height);
|
||||
const clip = {
|
||||
@@ -104,8 +125,10 @@ export class ShapeList extends SortableList {
|
||||
art.position.x += clip.x;
|
||||
art.position.y += clip.y;
|
||||
art.scale.set(scale);
|
||||
const drawing = shapeDrawing(shape, this.state.project);
|
||||
if (drawing) paintDrawing(art, drawing, this.paints);
|
||||
for (const shape of isVectorGroup(item) ? shapesIn(item.items) : [item]) {
|
||||
const drawing = shapeDrawing(shape, this.state.project);
|
||||
if (drawing) paintDrawing(art, drawing, this.paints);
|
||||
}
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
* the Texture tab edits a collision outline.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import {
|
||||
AddVectorShapeCommand,
|
||||
paintsOf,
|
||||
@@ -29,6 +30,7 @@ import { findAsset } from '../../core/model/Project';
|
||||
import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
||||
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
||||
import { allShapes, drawnShapes, outermostGroup, shapesIn } from '../../core/model/vectorTree';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
|
||||
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 { SheetCanvas } from '../texture/SheetCanvas';
|
||||
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
|
||||
@@ -68,12 +71,20 @@ export type PaintTarget = 'fill' | 'stroke';
|
||||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
||||
const SNAP_STEP = 8;
|
||||
|
||||
/** A gesture in progress. `before` is what the shape read when it began – the undo step's other side. */
|
||||
interface ShapeDrag {
|
||||
kind: 'point' | 'handle' | 'shape' | 'pen';
|
||||
/** One shape as a drag moves it. `before` is what it read when the drag began – the undo step's other side. */
|
||||
interface MovedShape {
|
||||
shapeId: string;
|
||||
before: 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. */
|
||||
ring: number;
|
||||
index: number;
|
||||
@@ -83,9 +94,8 @@ interface ShapeDrag {
|
||||
at: Vec2;
|
||||
moved: boolean;
|
||||
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;
|
||||
paints: ReturnType<typeof paintsOf>;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const at = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = POINT_SIZE / this.view.zoom;
|
||||
@@ -347,7 +363,15 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
const point = this.snapped(at);
|
||||
const open = this.openRing();
|
||||
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;
|
||||
this.state.select(shapeSelection(asset.id, [command.shapeId]));
|
||||
this.pen = { shapeId: command.shapeId, ring: 0 };
|
||||
@@ -393,6 +417,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
label: 'Draw shape',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: {},
|
||||
others: [],
|
||||
};
|
||||
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
|
||||
* 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.
|
||||
* Ctrl adds a shape to the selection or takes it out again, which is how shapes are picked for
|
||||
* combining (#187).
|
||||
* Ctrl or Shift adds a shape to the selection or takes it out again, as on a row of the list,
|
||||
* 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(
|
||||
asset: VectorArtAsset,
|
||||
@@ -412,7 +439,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
tolerance: number,
|
||||
e: FederatedPointerEvent,
|
||||
): void {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
const hit = this.shapeAt(asset, at);
|
||||
if (hit) {
|
||||
this.pick(null);
|
||||
@@ -432,7 +459,9 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
const shape = this.shapeAt(asset, at);
|
||||
if (shape) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -441,21 +470,36 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
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 {
|
||||
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 = {
|
||||
kind: 'shape',
|
||||
shapeId: shape.id,
|
||||
before: shape.rings,
|
||||
rings: shape.rings.map(cloneVectorRing),
|
||||
...moved(shape),
|
||||
others,
|
||||
ring: -1,
|
||||
index: -1,
|
||||
side: 'out',
|
||||
pointer: { x: local.x, y: local.y },
|
||||
at,
|
||||
moved: false,
|
||||
label: 'Move shape',
|
||||
label: others.length > 0 ? 'Move shapes' : 'Move shape',
|
||||
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. */
|
||||
private shapeAt(asset: VectorArtAsset, at: Vec2): VectorShape | undefined {
|
||||
for (let i = asset.shapes.length - 1; i >= 0; i--) {
|
||||
const shape = asset.shapes[i]!;
|
||||
// Only what is drawn: a shape on a hidden layer is not there to be pressed.
|
||||
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;
|
||||
}
|
||||
return undefined;
|
||||
@@ -543,6 +589,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
label: grip.kind === 'handle' ? 'Bend shape' : 'Move point',
|
||||
delta: { x: 0, y: 0 },
|
||||
paints: {},
|
||||
others: [],
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
@@ -564,9 +611,9 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
const ring = drag.rings[drag.ring];
|
||||
if (drag.kind === 'shape') {
|
||||
const delta = this.snapped({ x: to.x - drag.at.x, y: to.y - drag.at.y });
|
||||
const before = drag.before;
|
||||
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') {
|
||||
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
|
||||
} else if (ring && drag.kind === 'pen') {
|
||||
@@ -618,15 +665,17 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
return;
|
||||
}
|
||||
const drag = this.drag;
|
||||
const shape = drag && this.shape(drag.shapeId);
|
||||
if (!drag || !shape) return;
|
||||
shape.rings = drag.rings;
|
||||
// A whole shape takes its gradients along (#186), handles and all.
|
||||
if (drag.kind === 'shape') {
|
||||
if (!drag || !this.shape(drag.shapeId)) return;
|
||||
for (const part of [drag, ...drag.others]) {
|
||||
const shape = this.shape(part.shapeId);
|
||||
if (!shape) continue;
|
||||
shape.rings = part.rings;
|
||||
// A whole shape takes its gradients along (#186), handles and all.
|
||||
if (drag.kind !== 'shape') continue;
|
||||
const delta = drag.delta;
|
||||
if (drag.paints.paint) shape.paint = paintMoved(drag.paints.paint, delta.x, delta.y);
|
||||
if (drag.paints.strokePaint && shape.stroke)
|
||||
shape.stroke.paint = paintMoved(drag.paints.strokePaint, delta.x, delta.y);
|
||||
if (part.paints.paint) shape.paint = paintMoved(part.paints.paint, delta.x, delta.y);
|
||||
if (part.paints.strokePaint && shape.stroke)
|
||||
shape.stroke.paint = paintMoved(part.paints.strokePaint, delta.x, delta.y);
|
||||
}
|
||||
this.state.preview();
|
||||
}
|
||||
@@ -640,30 +689,36 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
this.drag = null;
|
||||
// The gesture is over, so a preview still waiting for its frame has nothing left to write.
|
||||
cancelFrameWork(this.frameKey);
|
||||
const shape = drag && this.shape(drag.shapeId);
|
||||
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 || !this.shape(drag.shapeId)) return;
|
||||
if (drag.kind === 'pen') this.pen = { shapeId: drag.shapeId, ring: drag.ring };
|
||||
const moved = drag.kind === 'shape' ? this.movedPaints(drag) : null;
|
||||
if (moved) {
|
||||
if (drag.paints.paint) shape.paint = drag.paints.paint;
|
||||
if (drag.paints.strokePaint && shape.stroke) shape.stroke.paint = drag.paints.strokePaint;
|
||||
const commands: SetVectorShapeRingsCommand[] = [];
|
||||
for (const part of [drag, ...drag.others]) {
|
||||
const shape = this.shape(part.shapeId);
|
||||
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))
|
||||
this.state.execute(
|
||||
new SetVectorShapeRingsCommand(this.assetId, drag.shapeId, drag.rings, drag.label, moved),
|
||||
);
|
||||
// Several shapes dragged together are one undo step.
|
||||
if (commands.length > 0) this.state.execute(oneStep(drag.label, commands));
|
||||
else this.state.preview();
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const { paint, strokePaint } = drag.paints;
|
||||
private movedPaints(part: MovedShape, delta: Vec2): ReturnType<typeof paintsOf> | null {
|
||||
const { paint, strokePaint } = part.paints;
|
||||
if (!paint && !strokePaint) return null;
|
||||
return {
|
||||
...(paint ? { paint: paintMoved(paint, drag.delta.x, drag.delta.y) } : {}),
|
||||
...(strokePaint ? { strokePaint: paintMoved(strokePaint, drag.delta.x, drag.delta.y) } : {}),
|
||||
...(paint ? { paint: paintMoved(paint, delta.x, 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.
|
||||
const onLine = grips.includes('segment');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -860,10 +915,12 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
: this.tool === 'pen'
|
||||
? this.drawing
|
||||
? '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.paintHint()
|
||||
: asset.shapes.length === 0
|
||||
: allShapes(asset).length === 0
|
||||
? 'Nothing drawn yet – take the Pen and click on the canvas.'
|
||||
: '';
|
||||
this.hint.text = text;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Asset, VectorArtAsset } from '../../core/model/Asset';
|
||||
import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { allShapes } from '../../core/model/vectorTree';
|
||||
import { Button, Dropdown, Menu, type Stack } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
@@ -95,7 +96,7 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
|
||||
protected override infoText(asset: VectorArtAsset): string {
|
||||
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'}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -46,6 +46,8 @@ const REMOVE_SLOT = 24;
|
||||
const VALUE_WIDTH = 44;
|
||||
/** Height of a header that only holds the plus; one with a title is a band (see `sections.ts`). */
|
||||
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. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
@@ -56,6 +58,8 @@ export interface SortableItem {
|
||||
hint?: string;
|
||||
/** Drawn faded, with a closed eye. */
|
||||
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. */
|
||||
@@ -94,6 +98,14 @@ export interface SortableListOptions {
|
||||
onPick: (id: string, toggle: boolean) => void;
|
||||
/** A row dropped somewhere else, in the owner's indices. */
|
||||
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. */
|
||||
onToggleHidden?: (id: string) => void;
|
||||
/** 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. */
|
||||
startY: number;
|
||||
y: number;
|
||||
startX: number;
|
||||
x: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -269,14 +283,14 @@ export class SortableList extends Widget implements SectionStart {
|
||||
thumbGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
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. */
|
||||
nameGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
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. */
|
||||
@@ -328,11 +342,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
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 {
|
||||
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 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);
|
||||
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;
|
||||
if (this.canRemove && x >= slots.duplicate) return 'cross';
|
||||
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.onRename && x >= this.textX && x < this.nameRight) return 'name';
|
||||
if (this.options.onRename && x >= indent + this.textX && x < this.nameRight) return 'name';
|
||||
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.
|
||||
const pick = this.selected.length !== 1 || this.selected[0] !== id;
|
||||
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 {
|
||||
@@ -488,7 +506,7 @@ export class SortableList extends Widget implements SectionStart {
|
||||
const renaming = this.renaming;
|
||||
if (!renaming) return;
|
||||
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(
|
||||
x,
|
||||
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 {
|
||||
const drag = this.drag;
|
||||
if (!drag) return;
|
||||
drag.y = this.toLocal(e.global).y - this.headerHeight;
|
||||
if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
const local = this.toLocal(e.global);
|
||||
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;
|
||||
this.invalidate();
|
||||
}
|
||||
@@ -513,12 +537,22 @@ export class SortableList extends Widget implements SectionStart {
|
||||
const id = this.shown[drag.from]?.id;
|
||||
if (drag.active) {
|
||||
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 (move) this.options.onMove(move.from, move.to);
|
||||
if (this.options.onDrop) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const top = this.headerHeight;
|
||||
this.background.clear();
|
||||
@@ -550,14 +584,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
|
||||
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
|
||||
|
||||
const indent = (item.depth ?? 0) * SORTABLE_INDENT;
|
||||
const thumbY = (SORTABLE_ROW_HEIGHT - THUMB) / 2;
|
||||
row.art.clear();
|
||||
row.art.position.set(PAD, thumbY);
|
||||
row.art.position.set(indent + PAD, thumbY);
|
||||
row.art.scale.set(1);
|
||||
row.clip.clear();
|
||||
if (thumbs) {
|
||||
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;
|
||||
|
||||
@@ -583,8 +618,12 @@ export class SortableList extends Widget implements SectionStart {
|
||||
const renamed = this.renaming?.id === item.id;
|
||||
row.label.visible = !renamed;
|
||||
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.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
|
||||
row.label.text = truncate(
|
||||
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;
|
||||
row.eye.clear();
|
||||
@@ -607,8 +646,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
}
|
||||
this.placeRenameField();
|
||||
if (this.drag?.active) {
|
||||
const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
|
||||
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
|
||||
const gap = dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
flattenRing,
|
||||
type ShapeBoolean,
|
||||
} from '../../src/core/model/vectorBoolean';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
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');
|
||||
f.stack.execute(combine);
|
||||
expect(combine.shapeId).toBe(low);
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([first, low]);
|
||||
const result = f.art().shapes[1]!;
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual([first, low]);
|
||||
const result = allShapes(f.art())[1]!;
|
||||
expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } });
|
||||
expect(paintedArea(result.rings)).toBe(300);
|
||||
});
|
||||
|
||||
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 before = JSON.stringify(f.art().shapes);
|
||||
const before = JSON.stringify(allShapes(f.art()));
|
||||
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();
|
||||
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||
expect(JSON.stringify(allShapes(f.art()))).toBe(before);
|
||||
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', () => {
|
||||
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(
|
||||
/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', () => {
|
||||
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]!]));
|
||||
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();
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual(before);
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../../src/core/model/vectorPaint';
|
||||
import { shapeDrawing } from '../../src/core/model/vectorPath';
|
||||
import { makeProject, roundTrip } from '../helpers';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
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', () => {
|
||||
it('is a new drawing when a stop, an end or the picture behind a texture changes', () => {
|
||||
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);
|
||||
shapeOf().paint = gradient();
|
||||
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', () => {
|
||||
const f = withTexture();
|
||||
const shapeNow = f.drawing().shapes[0]!;
|
||||
const shapeNow = allShapes(f.drawing())[0]!;
|
||||
shapeNow.paint = gradient({ type: 'radial' });
|
||||
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.stroke).toEqual(shapeNow.stroke);
|
||||
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', () => {
|
||||
const f = withTexture();
|
||||
const raw = JSON.parse(JSON.stringify(f.project)) as { assets: { shapes?: unknown[] }[] };
|
||||
const drawing = raw.assets.find((a) => Array.isArray(a.shapes))!;
|
||||
const written = drawing.shapes![0] as Record<string, unknown>;
|
||||
const raw = JSON.parse(JSON.stringify(f.project)) as {
|
||||
assets: { type: string; layers?: { items: unknown[] }[] }[];
|
||||
};
|
||||
const drawing = raw.assets.find((a) => a.type === 'vectorart')!;
|
||||
const written = drawing.layers![0]!.items[0] as Record<string, unknown>;
|
||||
written.paint = {
|
||||
type: 'linear',
|
||||
from: { x: 0, y: 0 },
|
||||
@@ -256,7 +259,7 @@ describe('a paint in the picture key and the project file', () => {
|
||||
width: 1,
|
||||
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.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', () => {
|
||||
const f = withTexture();
|
||||
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 }));
|
||||
expect(f.drawing().shapes[0]!.paint).toBeUndefined();
|
||||
expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
|
||||
f.stack.undo();
|
||||
expect(f.drawing().shapes[0]!.paint).toEqual(gradient());
|
||||
expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
|
||||
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', () => {
|
||||
@@ -289,7 +292,7 @@ describe('writing a paint', () => {
|
||||
|
||||
it('moves the paints along when a whole shape is dragged, and undo takes both back', () => {
|
||||
const f = withTexture();
|
||||
const shapeNow = f.drawing().shapes[0]!;
|
||||
const shapeNow = allShapes(f.drawing())[0]!;
|
||||
shapeNow.paint = gradient();
|
||||
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 })) }];
|
||||
@@ -298,10 +301,10 @@ describe('writing a paint', () => {
|
||||
strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0),
|
||||
};
|
||||
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(f.drawing().shapes[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 10, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 });
|
||||
f.stack.undo();
|
||||
expect(f.drawing().shapes[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]!.paint?.from).toEqual({ x: 0, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,13 @@ function shape(over: Partial<VectorShape> = {}): VectorShape {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/comm
|
||||
import {
|
||||
AddVectorShapeCommand,
|
||||
DuplicateVectorShapeCommand,
|
||||
MoveVectorShapeCommand,
|
||||
OrderVectorShapeCommand,
|
||||
RemoveVectorShapeCommand,
|
||||
RenameVectorShapeCommand,
|
||||
SetVectorShapeRingsCommand,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
isDrawableRing,
|
||||
SHAPE_FILL_DEFAULT,
|
||||
shapeExtent,
|
||||
shapeOrderTarget,
|
||||
VECTOR_CANVAS_DEFAULT,
|
||||
VECTOR_CANVAS_MAX,
|
||||
VECTOR_HEIGHT_KEY,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
vectorArtRevision,
|
||||
vectorCanvasSize,
|
||||
} from '../../src/core/model/vectorart';
|
||||
import { allShapes, shapeOrderTarget } from '../../src/core/model/vectorTree';
|
||||
import { makeProject, roundTrip } from '../helpers';
|
||||
|
||||
/** 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', () => {
|
||||
const f = drawing();
|
||||
expect(f.art().shapes).toEqual([]);
|
||||
expect(allShapes(f.art())).toEqual([]);
|
||||
expect(vectorCanvasSize(f.art().props)).toEqual({
|
||||
width: VECTOR_CANVAS_DEFAULT,
|
||||
height: VECTOR_CANVAS_DEFAULT,
|
||||
@@ -150,7 +150,9 @@ describe('the revision', () => {
|
||||
type: 'vectorart',
|
||||
name: 'A',
|
||||
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.
|
||||
const built = { closed: true, points: square().points };
|
||||
@@ -203,14 +205,14 @@ describe('editing the shapes', () => {
|
||||
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
|
||||
f.stack.execute(first);
|
||||
f.stack.execute(second);
|
||||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
|
||||
expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
|
||||
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(allShapes(f.art())[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
|
||||
expect(allShapes(f.art())[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
|
||||
|
||||
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();
|
||||
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', () => {
|
||||
@@ -221,13 +223,13 @@ describe('editing the shapes', () => {
|
||||
return add.shapeId;
|
||||
});
|
||||
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();
|
||||
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.
|
||||
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', () => {
|
||||
@@ -240,7 +242,7 @@ describe('editing the shapes', () => {
|
||||
const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId);
|
||||
f.stack.execute(copy);
|
||||
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[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 });
|
||||
expect(shapes[2]!.rings).toEqual(shapes[0]!.rings);
|
||||
@@ -249,7 +251,7 @@ describe('editing the shapes', () => {
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -261,19 +263,19 @@ describe('editing the shapes', () => {
|
||||
return add.shapeId;
|
||||
});
|
||||
// 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(f.art().shapes, ids[0]!, 'front')).toBe(2);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'forward')).toBe(1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'front')).toBe(2);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'backward')).toBe(1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[1]!, 'back')).toBe(0);
|
||||
// Already there, and not there at all.
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1);
|
||||
expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'front')).toBe(-1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'back')).toBe(-1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), 'nobody', 'front')).toBe(-1);
|
||||
|
||||
f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2));
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
|
||||
f.stack.execute(new OrderVectorShapeCommand(f.assetId, ids[0]!, 'front'));
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
|
||||
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', () => {
|
||||
@@ -283,15 +285,15 @@ describe('editing the shapes', () => {
|
||||
const rings = [square()];
|
||||
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
|
||||
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);
|
||||
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(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
|
||||
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
|
||||
expect(allShapes(f.art())[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
|
||||
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
|
||||
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false);
|
||||
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
|
||||
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', () => {
|
||||
@@ -306,16 +308,16 @@ describe('editing the shapes', () => {
|
||||
}),
|
||||
);
|
||||
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
|
||||
expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
|
||||
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||||
expect(allShapes(f.art())[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
|
||||
expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||||
|
||||
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();
|
||||
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();
|
||||
expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||||
expect(f.art().shapes[0]!.stroke).toBeUndefined();
|
||||
expect(allShapes(f.art())[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||||
expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
|
||||
});
|
||||
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
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', () => {
|
||||
const f = drawing();
|
||||
const art = f.art() as unknown as Record<string, unknown>;
|
||||
delete art.layers;
|
||||
art.shapes = [
|
||||
{
|
||||
id: 'one',
|
||||
@@ -396,8 +399,8 @@ describe('what a project file may say', () => {
|
||||
'not a shape',
|
||||
];
|
||||
const loaded = f.art(roundTrip(f.project));
|
||||
expect(loaded.shapes).toHaveLength(1);
|
||||
const shape = loaded.shapes[0]!;
|
||||
expect(allShapes(loaded)).toHaveLength(1);
|
||||
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 points fall away entirely.
|
||||
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', () => {
|
||||
const f = drawing();
|
||||
const art = f.art() as unknown as Record<string, unknown>;
|
||||
delete art.layers;
|
||||
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.rings).toEqual([{ closed: false, points: [] }]);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { EditorState } from '../../src/editor/state';
|
||||
import { VStack } from '../../src/ui';
|
||||
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
|
||||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
/** One frame of the render loop, the way the ticker runs it. */
|
||||
function frames(): () => void {
|
||||
@@ -188,7 +189,7 @@ describe('dragging a colour stop of a gradient (#274)', () => {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||||
asset.shapes.push({
|
||||
asset.layers[0]!.items.push({
|
||||
id: 's',
|
||||
name: 'Sky',
|
||||
fill: 0x0000ff,
|
||||
@@ -219,7 +220,7 @@ describe('dragging a colour stop of a gradient (#274)', () => {
|
||||
bar.resize(112, 32);
|
||||
const pointer = (type: string, x: number) =>
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
|
||||
import { resetRenderLoop } from '../../src/ui/render';
|
||||
import type { DrawItem } from '../../src/viewport/items';
|
||||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
/** Presses run on one clock, far enough apart that no two join unless a test asks for a double-click. */
|
||||
let clock = 0;
|
||||
@@ -207,7 +208,7 @@ describe('the vector art tab', () => {
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 10, y: 50 },
|
||||
];
|
||||
asset.shapes.push({
|
||||
asset.layers[0]!.items.push({
|
||||
id: 's',
|
||||
name: 'Square',
|
||||
fill: 0x0000ff,
|
||||
@@ -221,7 +222,7 @@ describe('the vector art tab', () => {
|
||||
canvas.redraw();
|
||||
state.select(shapeSelection(asset.id, ['s']));
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -257,13 +258,13 @@ describe('the vector art tab', () => {
|
||||
expect(canvas.pickedKind).toBe('straight');
|
||||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||||
expect(points()).toHaveLength(3);
|
||||
expect(asset.shapes).toHaveLength(1);
|
||||
expect(allShapes(asset)).toHaveLength(1);
|
||||
state.undo();
|
||||
expect(points()).toHaveLength(4);
|
||||
click(at(30, 30));
|
||||
expect(canvas.pickedKind).toBe(null);
|
||||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||||
expect(asset.shapes).toHaveLength(0);
|
||||
expect(allShapes(asset)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '../../src/editor/inspector/shapeSection';
|
||||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||||
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
|
||||
@@ -85,7 +86,7 @@ function withShapes(count: number): { host: InspectorHost; assetId: string; ids:
|
||||
}
|
||||
|
||||
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[] {
|
||||
const found: T[] = [];
|
||||
@@ -239,7 +240,7 @@ describe('combining from the inspector (#187)', () => {
|
||||
buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]);
|
||||
expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.');
|
||||
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[1],
|
||||
]);
|
||||
|
||||
@@ -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
@@ -100,7 +100,8 @@ export async function vectorArt(t) {
|
||||
const scene = __gm.state.activeScene;
|
||||
${MAKE_OBJECT}
|
||||
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'));
|
||||
sprite.id = 'va-sprite';
|
||||
sprite.name = 'Emblem sprite';
|
||||
@@ -163,7 +164,7 @@ export async function vectorArt(t) {
|
||||
const before = picture?.file;
|
||||
await mutate(`
|
||||
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 sleep(300);
|
||||
const after = await json("__gm.drawnPicture('va-art')");
|
||||
@@ -745,7 +746,7 @@ export async function vectorArt(t) {
|
||||
});
|
||||
await mutate(`
|
||||
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)} })`);
|
||||
await settle();
|
||||
const selectedIds = () => json('__gm.vectorArt.selectedIds()');
|
||||
@@ -837,7 +838,7 @@ export async function vectorArt(t) {
|
||||
};
|
||||
await mutate(`
|
||||
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)} })`);
|
||||
await settle();
|
||||
const onDisc = await screenAt(170, 120);
|
||||
@@ -892,7 +893,7 @@ export async function vectorArt(t) {
|
||||
};
|
||||
await mutate(`
|
||||
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'] })`);
|
||||
await evaluate(`__gm.openDocument('asset:${madeId}')`);
|
||||
await settle();
|
||||
@@ -1124,4 +1125,167 @@ export async function vectorArt(t) {
|
||||
JSON.stringify({ stroke: linedPaint?.stroke, fillTo: linedPaint?.paint?.to }),
|
||||
);
|
||||
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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user