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

A vector drawing was one flat list of shapes. With many shapes the
list got hard to read, and several shapes could not be kept or moved
together. Now the shapes stand on layers, as in pixel art, and can be
put into groups that nest.

- A drawing has layers, each with a name and an eye. A hidden layer
  is neither in the tab nor in the picture a sprite wears, and its
  shapes cannot be pressed. One layer is the one worked on: the pen
  and Add shape draw onto it, and SHAPES lists what is on it.
- The inspector has a list LAYERS over the shapes. Its plus adds a
  layer, the cross on a row removes it with all on it, the last one
  stays. A double-click renames, a drag sorts.
- Group (Ctrl+G) puts the selected shapes into a group, Ungroup
  (Ctrl+Shift+G) takes it away. A group stands over its rows in the
  list, the rows one step in, and a row is dragged into a group or
  out of it.
- In the tab a press on a shape takes its outermost group, and a drag
  moves the group as one undo step.
- The model has layers with a tree of shapes and groups instead of a
  flat shapes array. The rules are pure functions in
  core/model/vectorTree.ts. A drawing saved before opens with all its
  shapes on one layer.
- SortableList takes rows with a depth and drops into a tree. The
  flat lists of tilemap and pixel art run as before.

Decided here:

- A group has no transformation of its own and cannot be hidden or
  locked. Rotating and scaling is the follow-up #291.
- A group is never empty: the one that loses its last shape goes
  with it.
- Several selected shapes are dragged together, grouped or not, and
  a click on a shape of the selection keeps the selection. This is
  what #217 asked for.
- Shift+click selects like Ctrl+click. With Select, Shift pans only
  beside the shapes, as in the scene view. The middle button pans
  anywhere, and with the pen and Paint Shift pans as before.
- A row of the list picks one shape of a group alone.
- Shapes change their layer through a row Layer in the inspector,
  which is there from two layers on. Rows are not dragged between
  layers.
- A group cannot span two layers, the button is off then.
- The pen does not draw onto a hidden layer, the hint says so.
- Groups are always shown unfolded.
- The four order buttons work inside the own group or layer.

Tests:
- Gate: 204 files, 3067 tests (3008 + 59), exit 0
- e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change:
  209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned
  and CI measures it.
- 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in
  tests/editor/vector-layers.test.ts
- Six e2e checks with a real mouse in block 47j
- 47 unit counter-probes for layers and groups and three for the
  Shift click all fail tests, run on stein2
- Three browser counter-probes fail their checks
- Two screenshots looked at (group in the list, hidden layer)
- Only unit tested: the row Layer, sorting and removing layers,
  copying a group, the Shift pan beside the shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 23:13:43 +02:00
1 parent 393caadc9d
commit 2ff3a78607
30 files changed
+2625 -408

No files matched your search

+26 -2
View File
@@ -400,7 +400,7 @@ export type VectorPaint = VectorGradient | VectorTextureFill;
/**
* One filled figure. `fill` is a solid colour and `opacity` scales it; a `paint` (#186) fills with a
* 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[];
}
/**
+3 -2
View File
@@ -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);
}
+469
View File
@@ -0,0 +1,469 @@
/**
* The layers of a vector art asset and the tree of shapes and groups in each (#190): how it is read,
* and every way it changes.
*
* The edits here take the layers and hand back new ones. No list is changed in place, so a command
* keeps the layers it found and puts them back on undo, whatever the edit did to them. The shapes
* themselves are passed along as they are – a point dragged afterwards is still the same shape.
*/
import type { VectorArtAsset, VectorGroup, VectorItem, VectorLayer, VectorShape } from './Asset';
export class VectorTreeError extends Error {}
/** Where an item lands when it is moved in the drawing order (the user's four buttons of #129). */
export type ShapeOrderMove = 'forward' | 'front' | 'backward' | 'back';
/**
* The index `itemId` moves to, or -1 when it is already there or not in the list. The list order is
* the drawing order and the last item lies on top, so "forward" is one step towards the end.
*/
export function shapeOrderTarget(
items: readonly { id: string }[],
itemId: string,
move: ShapeOrderMove,
): number {
const from = items.findIndex((item) => item.id === itemId);
if (from < 0) return -1;
const last = items.length - 1;
const to =
move === 'forward'
? Math.min(from + 1, last)
: move === 'front'
? last
: move === 'backward'
? Math.max(from - 1, 0)
: 0;
return to === from ? -1 : to;
}
export function isVectorGroup(item: VectorItem): item is VectorGroup {
return 'items' in item;
}
/** The shapes under `items` in drawing order, the last one on top. */
export function shapesIn(items: readonly VectorItem[]): VectorShape[] {
return items.flatMap((item) => (isVectorGroup(item) ? shapesIn(item.items) : [item]));
}
/** Every shape of the drawing in drawing order, those of hidden layers included. */
export function allShapes(asset: VectorArtAsset): VectorShape[] {
return asset.layers.flatMap((layer) => shapesIn(layer.items));
}
/** What is drawn: the shapes of the layers that are shown. */
export function drawnShapes(asset: VectorArtAsset): VectorShape[] {
return asset.layers.flatMap((layer) => (layer.hidden ? [] : shapesIn(layer.items)));
}
/** Where an item stands: its layer, the group it is in (null at the top of the layer) and its place there. */
export interface ItemPlace {
layer: VectorLayer;
parent: VectorGroup | null;
siblings: VectorItem[];
index: number;
item: VectorItem;
/** How many groups it is inside of. */
depth: number;
}
export function placeOf(layers: readonly VectorLayer[], itemId: string): ItemPlace | null {
for (const layer of layers) {
const place = placeIn(layer, null, layer.items, itemId, 0);
if (place) return place;
}
return null;
}
function placeIn(
layer: VectorLayer,
parent: VectorGroup | null,
siblings: VectorItem[],
itemId: string,
depth: number,
): ItemPlace | null {
for (const [index, item] of siblings.entries()) {
if (item.id === itemId) return { layer, parent, siblings, index, item, depth };
const below = isVectorGroup(item) ? placeIn(layer, item, item.items, itemId, depth + 1) : null;
if (below) return below;
}
return null;
}
export function findVectorItem(asset: VectorArtAsset, itemId: string): VectorItem | undefined {
return placeOf(asset.layers, itemId)?.item;
}
export function layerOfItem(asset: VectorArtAsset, itemId: string): VectorLayer | undefined {
return placeOf(asset.layers, itemId)?.layer;
}
/** The outermost group a shape is in; null for a shape that stands in its layer directly. */
export function outermostGroup(layers: readonly VectorLayer[], itemId: string): VectorGroup | null {
for (const layer of layers)
for (const item of layer.items)
if (isVectorGroup(item) && item.id !== itemId && placeIn(layer, item, item.items, itemId, 1))
return item;
return null;
}
/**
* What a selection of shapes stands for in the tree: a group all of whose shapes are selected counts
* as itself, the outermost such group first, and every other selected shape as itself. In drawing order.
*/
export function selectedItems(layers: readonly VectorLayer[], shapeIds: readonly string[]): string[] {
const picked = new Set(shapeIds);
const walk = (items: readonly VectorItem[]): string[] =>
items.flatMap((item) => {
if (!isVectorGroup(item)) return picked.has(item.id) ? [item.id] : [];
const shapes = shapesIn(item.items);
return shapes.length > 0 && shapes.every((shape) => picked.has(shape.id))
? [item.id]
: walk(item.items);
});
return layers.flatMap((layer) => walk(layer.items));
}
/** One row of the tree as a list shows it. */
export interface TreeRow {
id: string;
name: string;
depth: number;
group: boolean;
}
/**
* A layer's tree as rows, top first: the item drawn last is the first row, and a group stands over
* what is in it.
*/
export function treeRows(items: readonly VectorItem[], depth = 0): TreeRow[] {
const rows: TreeRow[] = [];
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]!;
const group = isVectorGroup(item);
rows.push({ id: item.id, name: item.name, depth, group });
if (group) rows.push(...treeRows(item.items, depth + 1));
}
return rows;
}
/** A place in a layer's tree: in which group (null is the layer itself) and over which sibling. */
export interface TreeDrop {
parentId: string | null;
/** The sibling it is drawn right over – the row shown under it. Null puts it lowest in its list. */
belowId: string | null;
}
/**
* The place a row dragged into `gap` lands in, or null when that is no place for it: where it already
* is, or inside itself. `gap` counts the boundaries between rows, 0 being above the first. Between a
* deep row and a shallower one the gap belongs to several lists at once – the end of the inner group,
* or the outer list after it – and `depth`, read from how far right the pointer is, says which.
*/
export function treeDrop(
rows: readonly TreeRow[],
dragged: string,
gap: number,
depth: number,
): TreeDrop | null {
const landing = landingOf(rows, dragged, gap, depth);
if (!landing) return null;
const { rest, at, level } = landing;
const parentId = level === 0 ? null : ancestorAt(rest, at - 1, level - 1);
if (level > 0 && parentId === null) return null;
const belowId = nextAtLevel(rest, at, level);
const was = rowPlace(
rows,
rows.findIndex((row) => row.id === dragged),
);
return was.parentId === parentId && was.belowId === belowId ? null : { parentId, belowId };
}
/** How deep that drop lands – where a list draws its marker; null where `treeDrop` has no place. */
export function treeDropDepth(
rows: readonly TreeRow[],
dragged: string,
gap: number,
depth: number,
): number | null {
return treeDrop(rows, dragged, gap, depth) ? (landingOf(rows, dragged, gap, depth)?.level ?? null) : null;
}
/** The rows without the dragged ones – the model takes them out first – and where the gap is in them. */
function landingOf(
rows: readonly TreeRow[],
dragged: string,
gap: number,
depth: number,
): { rest: TreeRow[]; at: number; level: number } | null {
const from = rows.findIndex((row) => row.id === dragged);
if (from < 0) return null;
const end = subtreeEnd(rows, from);
if (gap > from && gap < end) return null;
const rest = [...rows.slice(0, from), ...rows.slice(end)];
const at = Math.max(0, Math.min(rest.length, gap <= from ? gap : gap - (end - from)));
const above = rest[at - 1];
const below = rest[at];
// A group's rows follow it one step deeper; a gap right under a group is its topmost place.
const deepest = above ? above.depth + (above.group ? 1 : 0) : 0;
const shallowest = below ? below.depth : 0;
return { rest, at, level: Math.max(shallowest, Math.min(deepest, Math.round(depth))) };
}
/** The first row at `level` from `index` on, before the list at that level ends. */
function nextAtLevel(rows: readonly TreeRow[], index: number, level: number): string | null {
for (let i = index; i < rows.length; i++) {
const row = rows[i]!;
if (row.depth < level) return null;
if (row.depth === level) return row.id;
}
return null;
}
/** One past the last row of the subtree that starts at `index`. */
function subtreeEnd(rows: readonly TreeRow[], index: number): number {
const depth = rows[index]!.depth;
let end = index + 1;
while (end < rows.length && rows[end]!.depth > depth) end += 1;
return end;
}
/** The group at `depth` that the row at `index` is in, or is. */
function ancestorAt(rows: readonly TreeRow[], index: number, depth: number): string | null {
for (let i = index; i >= 0; i--) {
const row = rows[i]!;
if (row.depth === depth) return row.group ? row.id : null;
if (row.depth < depth) return null;
}
return null;
}
function rowPlace(rows: readonly TreeRow[], index: number): TreeDrop {
const depth = rows[index]!.depth;
const parentId = depth === 0 ? null : ancestorAt(rows, index - 1, depth - 1);
return { parentId, belowId: nextAtLevel(rows, subtreeEnd(rows, index), depth) };
}
// ---------------------------------------------------------------------------------------------
// Edits. Each returns new layers and leaves the given ones as they were.
// ---------------------------------------------------------------------------------------------
/** Rebuilds every list through `edit`, innermost first; a group left empty goes. */
function mapLists(
layers: readonly VectorLayer[],
edit: (items: VectorItem[], owner: VectorLayer | VectorGroup) => VectorItem[],
): VectorLayer[] {
const rebuilt = (items: readonly VectorItem[], owner: VectorLayer | VectorGroup): VectorItem[] => {
const next: VectorItem[] = [];
for (const item of items) {
if (!isVectorGroup(item)) next.push(item);
else {
const inner = rebuilt(item.items, item);
if (inner.length > 0) next.push({ ...item, items: inner });
}
}
return edit(next, owner);
};
return layers.map((layer) => ({ ...layer, items: rebuilt(layer.items, layer) }));
}
function requirePlace(layers: readonly VectorLayer[], itemId: string): ItemPlace {
const place = placeOf(layers, itemId);
if (!place) throw new VectorTreeError(`Shape ${itemId} not found`);
return place;
}
function requireLayer(layers: readonly VectorLayer[], layerId: string): VectorLayer {
const layer = layers.find((l) => l.id === layerId);
if (!layer) throw new VectorTreeError(`Layer ${layerId} not found`);
return layer;
}
/** `moved` put into `items`: right over `drop.belowId`, lowest when that is null, on top without a drop. */
function placedIn(
items: readonly VectorItem[],
moved: readonly VectorItem[],
drop: TreeDrop | null,
): VectorItem[] {
if (!drop) return [...items, ...moved];
const at = drop.belowId === null ? -1 : items.findIndex((other) => other.id === drop.belowId);
return [...items.slice(0, at + 1), ...moved, ...items.slice(at + 1)];
}
/** Puts `item` into the layer: at `drop`, or on top of the layer without one. */
export function withItemAdded(
layers: readonly VectorLayer[],
layerId: string,
item: VectorItem,
drop: TreeDrop | null = null,
): VectorLayer[] {
const ownerId = requireOwner(layers, layerId, drop);
return mapLists(layers, (items, owner) => (owner.id === ownerId ? placedIn(items, [item], drop) : items));
}
/** The id of the list a drop goes into: the layer's own, or that of a group standing in it. */
function requireOwner(layers: readonly VectorLayer[], layerId: string, drop: TreeDrop | null): string {
const layer = requireLayer(layers, layerId);
const parentId = drop?.parentId ?? null;
if (parentId === null) return layerId;
const place = placeIn(layer, null, layer.items, parentId, 0);
if (!place || !isVectorGroup(place.item)) throw new VectorTreeError(`Group ${parentId} not found`);
return parentId;
}
/** Takes items out, a group with all that is in it. */
export function withItemsRemoved(layers: readonly VectorLayer[], itemIds: readonly string[]): VectorLayer[] {
const gone = new Set(itemIds);
const found = itemIds.filter((id) => placeOf(layers, id));
if (found.length === 0) throw new VectorTreeError('No such shape');
return mapLists(layers, (items) => items.filter((item) => !gone.has(item.id)));
}
/**
* Moves items to `drop` in the layer, or on top of the layer without one. They keep the order they
* are drawn in among themselves.
*/
export function withItemsMoved(
layers: readonly VectorLayer[],
itemIds: readonly string[],
layerId: string,
drop: TreeDrop | null = null,
): VectorLayer[] {
const moved = inDrawingOrder(layers, itemIds);
if (moved.length === 0) throw new VectorTreeError('No such shape');
const parentId = drop?.parentId ?? null;
for (const item of moved)
if (parentId !== null && (item.id === parentId || contains(item, parentId)))
throw new VectorTreeError('A group cannot be moved into itself');
const ownerId = requireOwner(layers, layerId, drop);
const gone = new Set(moved.map((item) => item.id));
// Not pruned while they are out: the group they go into may be the one they leave empty.
const put = (items: readonly VectorItem[], ownerHere: string): VectorItem[] => {
const kept: VectorItem[] = [];
for (const item of items) {
if (gone.has(item.id)) continue;
kept.push(isVectorGroup(item) ? { ...item, items: put(item.items, item.id) } : item);
}
return ownerHere === ownerId ? placedIn(kept, moved, drop) : kept;
};
const next = layers.map((layer) => ({ ...layer, items: put(layer.items, layer.id) }));
return mapLists(next, (items) => items);
}
function contains(item: VectorItem, itemId: string): boolean {
return isVectorGroup(item) && item.items.some((inner) => inner.id === itemId || contains(inner, itemId));
}
/** The named items in drawing order; one that stands inside another named one is left out. */
function inDrawingOrder(layers: readonly VectorLayer[], itemIds: readonly string[]): VectorItem[] {
const wanted = new Set(itemIds);
const walk = (items: readonly VectorItem[]): VectorItem[] =>
items.flatMap((item) => (wanted.has(item.id) ? [item] : isVectorGroup(item) ? walk(item.items) : []));
return layers.flatMap((layer) => walk(layer.items));
}
/**
* Puts items into a new group. It stands where the topmost of them stood, and they keep the order
* they are drawn in. Items of several layers cannot share a group.
*/
export function withItemsGrouped(
layers: readonly VectorLayer[],
itemIds: readonly string[],
group: { id: string; name: string },
): VectorLayer[] {
const members = inDrawingOrder(layers, itemIds);
const top = members[members.length - 1];
if (!top) throw new VectorTreeError('Nothing to group');
const places = members.map((item) => requirePlace(layers, item.id));
const place = places[places.length - 1]!;
if (places.some((p) => p.layer !== place.layer))
throw new VectorTreeError('Shapes of different layers cannot be grouped');
const gone = new Set(members.map((item) => item.id));
const ownerId = place.parent?.id ?? place.layer.id;
const made: VectorGroup = { id: group.id, name: group.name, items: members };
const put = (items: readonly VectorItem[], ownerHere: string): VectorItem[] => {
const kept: VectorItem[] = [];
for (const item of items) {
if (item.id === top.id && ownerHere === ownerId) kept.push(made);
else if (!gone.has(item.id))
kept.push(isVectorGroup(item) ? { ...item, items: put(item.items, item.id) } : item);
}
return kept;
};
const next = layers.map((layer) => ({ ...layer, items: put(layer.items, layer.id) }));
return mapLists(next, (items) => items);
}
/** Takes a group away and leaves what was in it in its place. */
export function withGroupDissolved(layers: readonly VectorLayer[], groupId: string): VectorLayer[] {
const place = requirePlace(layers, groupId);
if (!isVectorGroup(place.item)) throw new VectorTreeError(`Group ${groupId} not found`);
return mapLists(layers, (items) =>
items.flatMap((item) => (item.id === groupId && isVectorGroup(item) ? item.items : [item])),
);
}
/** One step, or all the way, among the items of its own list; null when it is there already. */
export function withItemOrdered(
layers: readonly VectorLayer[],
itemId: string,
move: ShapeOrderMove,
): VectorLayer[] | null {
const place = requirePlace(layers, itemId);
const to = shapeOrderTarget(place.siblings, itemId, move);
if (to < 0) return null;
const ownerId = place.parent?.id ?? place.layer.id;
return mapLists(layers, (items, owner) => {
if (owner.id !== ownerId) return items;
const next = items.filter((item) => item.id !== itemId);
next.splice(to, 0, place.item);
return next;
});
}
/** Whether the item can take that step – what switches an order button on. */
export function canOrderItem(layers: readonly VectorLayer[], itemId: string, move: ShapeOrderMove): boolean {
const place = placeOf(layers, itemId);
return place !== null && shapeOrderTarget(place.siblings, itemId, move) >= 0;
}
/** A new layer right over the layer `overId`, or on top of all without one. */
export function withLayerAdded(
layers: readonly VectorLayer[],
layer: VectorLayer,
overId = '',
): VectorLayer[] {
if (layers.some((l) => l.id === layer.id)) throw new VectorTreeError(`Layer ${layer.id} is there already`);
const at = layers.findIndex((l) => l.id === overId);
return at < 0 ? [...layers, layer] : [...layers.slice(0, at + 1), layer, ...layers.slice(at + 1)];
}
/** A drawing keeps one layer: there has to be somewhere to draw. */
export function withLayerRemoved(layers: readonly VectorLayer[], layerId: string): VectorLayer[] {
requireLayer(layers, layerId);
if (layers.length <= 1) throw new VectorTreeError('A drawing needs one layer');
return layers.filter((layer) => layer.id !== layerId);
}
export function withLayerMoved(layers: readonly VectorLayer[], from: number, to: number): VectorLayer[] {
const inside = (index: number) => Number.isInteger(index) && index >= 0 && index < layers.length;
if (!inside(from) || !inside(to)) throw new VectorTreeError('No such layer');
const next = [...layers];
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
export function withLayerChanged(
layers: readonly VectorLayer[],
layerId: string,
change: { name?: string; hidden?: boolean },
): VectorLayer[] {
requireLayer(layers, layerId);
return layers.map((layer) => {
if (layer.id !== layerId) return layer;
const next: VectorLayer = { ...layer, name: change.name ?? layer.name };
const hidden = change.hidden ?? layer.hidden === true;
if (hidden) next.hidden = true;
else delete next.hidden;
return next;
});
}
+4 -28
View File
@@ -13,6 +13,7 @@ import { fingerprint, type GeneratedPicture } from './generated';
import type { Project } from './Project';
import type { 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;
}