/** * 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; }); }