diff --git a/README.md b/README.md index 9b8741f1..6e72dfee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index c27285a3..84655060 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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 { diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index abdc4448..0036c405 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -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`); diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index 5a738c91..2a19b82b 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -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 | 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[] = []; - - 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 = { */ export class CombineVectorShapesCommand implements Command { readonly label: string; - private removed: Removed[] = []; + 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((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 }), + ); } } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index a4b12ce7..51e1de14 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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[]; } /** diff --git a/src/core/model/vectorPath.ts b/src/core/model/vectorPath.ts index 9993658c..2ce685f0 100644 --- a/src/core/model/vectorPath.ts +++ b/src/core/model/vectorPath.ts @@ -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); } diff --git a/src/core/model/vectorTree.ts b/src/core/model/vectorTree.ts new file mode 100644 index 00000000..dc5b9cb9 --- /dev/null +++ b/src/core/model/vectorTree.ts @@ -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; + }); +} diff --git a/src/core/model/vectorart.ts b/src/core/model/vectorart.ts index 38ba4968..f2992ac3 100644 --- a/src/core/model/vectorart.ts +++ b/src/core/model/vectorart.ts @@ -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; -} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 026c0925..a368dee7 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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): VectorLayer[] { + const taken = new Set(); + 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): 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, 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 : []) { diff --git a/src/editor/debug/vectorArtHooks.ts b/src/editor/debug/vectorArtHooks.ts index 8e2453ac..a3d78d17 100644 --- a/src/editor/debug/vectorArtHooks.ts +++ b/src/editor/debug/vectorArtHooks.ts @@ -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; diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index 2468089a..ed6afc7b 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -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), { diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 3169f6db..69ecbfdd 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -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') diff --git a/src/editor/state.ts b/src/editor/state.ts index b1dcf2b7..f1d6437b 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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 }); } diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index 6f580cfe..22cc7428 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -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 extends Widget { @@ -44,6 +44,13 @@ export abstract class SheetCanvas 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 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; diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index 389f7d7f..e796881b 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -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; } } diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 3a2e0da0..0eb30c5e 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -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; +} + +/** 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; } /** 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 { } } + /** 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 { 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 { label: 'Draw shape', delta: { x: 0, y: 0 }, paints: {}, + others: [], }; this.preview(); } @@ -402,8 +427,10 @@ export class VectorArtCanvas extends SheetCanvas { * 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 { 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 { 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 { 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 { /** 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 { 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 { 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 { 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 { 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 | null { - const { paint, strokePaint } = drag.paints; + private movedPaints(part: MovedShape, delta: Vec2): ReturnType | 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 { // 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 { : 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; diff --git a/src/editor/vectorart/VectorArtEditor.ts b/src/editor/vectorart/VectorArtEditor.ts index 4a76ed1d..e8c2f05f 100644 --- a/src/editor/vectorart/VectorArtEditor.ts +++ b/src/editor/vectorart/VectorArtEditor.ts @@ -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(); + +/** `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(); +} diff --git a/src/editor/vectorart/grouping.ts b/src/editor/vectorart/grouping.ts new file mode 100644 index 00000000..cb2c144b --- /dev/null +++ b/src/editor/vectorart/grouping.ts @@ -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)); +} diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index 9b1c370f..c9aeb603 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -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); + } } } diff --git a/tests/core/vector-boolean.test.ts b/tests/core/vector-boolean.test.ts index ec7c2f23..7c193b3e 100644 --- a/tests/core/vector-boolean.test.ts +++ b/tests/core/vector-boolean.test.ts @@ -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); }); }); diff --git a/tests/core/vector-paint.test.ts b/tests/core/vector-paint.test.ts index 741f763f..08683d95 100644 --- a/tests/core/vector-paint.test.ts +++ b/tests/core/vector-paint.test.ts @@ -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; + 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; 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 }); }); }); diff --git a/tests/core/vector-path.test.ts b/tests/core/vector-path.test.ts index 38f40192..60df02fb 100644 --- a/tests/core/vector-path.test.ts +++ b/tests/core/vector-path.test.ts @@ -27,7 +27,13 @@ function shape(over: Partial = {}): 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', () => { diff --git a/tests/core/vector-tree.test.ts b/tests/core/vector-tree.test.ts new file mode 100644 index 00000000..2ad96a39 --- /dev/null +++ b/tests/core/vector-tree.test.ts @@ -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; + 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; + 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).layers = []; + const loaded = f.art(roundTrip(f.project)); + expect(loaded.layers).toHaveLength(1); + expect(loaded.layers[0]!.items).toEqual([]); + }); +}); diff --git a/tests/core/vectorart.test.ts b/tests/core/vectorart.test.ts index 0b446283..d9b825e4 100644 --- a/tests/core/vectorart.test.ts +++ b/tests/core/vectorart.test.ts @@ -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; + 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; + 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: [] }]); }); diff --git a/tests/editor/frame-preview.test.ts b/tests/editor/frame-preview.test.ts index 9c9e3be3..827d9ef3 100644 --- a/tests/editor/frame-preview.test.ts +++ b/tests/editor/frame-preview.test.ts @@ -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 }; } diff --git a/tests/editor/point-gestures.test.ts b/tests/editor/point-gestures.test.ts index 55ac2e57..472204cb 100644 --- a/tests/editor/point-gestures.test.ts +++ b/tests/editor/point-gestures.test.ts @@ -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); }); }); diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts index 372e5f6a..96a8ec5f 100644 --- a/tests/editor/shape-rows.test.ts +++ b/tests/editor/shape-rows.test.ts @@ -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(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], ]); diff --git a/tests/editor/vector-layers.test.ts b/tests/editor/vector-layers.test.ts new file mode 100644 index 00000000..409c59a8 --- /dev/null +++ b/tests/editor/vector-layers.test.ts @@ -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 = (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); + }); +}); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 0ba5c1e0..3052311c 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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 }, + ], + }; }