diff --git a/editor/document-indexes.test.ts b/editor/document-indexes.test.ts new file mode 100644 index 0000000..1ed8208 --- /dev/null +++ b/editor/document-indexes.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; +import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes"; + +const document: ImageDocument = { + id: "d1", + name: "Indexed Document", + version: 1, + assets: [ + { id: "asset-target", name: "Target", mimeType: "image/png", source: "asset://target", intrinsicSize: { w: 100, h: 50 } }, + { id: "asset-mask", name: "Mask", mimeType: "image/png", source: "asset://mask", intrinsicSize: { w: 100, h: 50 } }, + { id: "asset-nested", name: "Nested", mimeType: "image/png", source: "asset://nested", intrinsicSize: { w: 20, h: 10 } }, + ], + artboards: [ + { + id: "a1", + name: "Artboard", + bounds: { x: 0, y: 0, w: 400, h: 300 }, + backgroundColor: "transparent", + visible: true, + locked: false, + layers: [ + raster("mask", "Mask", "asset-mask"), + { ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } }, + group("group", "Group", [ + raster("nested-mask", "Nested Mask", "asset-mask"), + { ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } }, + ]), + ], + }, + ], +}; + +describe("document read indexes", () => { + test("indexes assets, layers, layer info, masks, and display counts", () => { + const index = createDocumentReadIndex(document); + + expect(index.assetById.get("asset-target")).toBe(document.assets[0]); + expect(index.layerById.get("nested-target")?.name).toBe("Nested Target"); + expect(index.layerInfoById.get("target")).toMatchObject({ artboardId: "a1", index: 1 }); + expect(index.layerInfoById.get("nested-target")).toMatchObject({ artboardId: "a1", parentGroupId: "group", index: 1 }); + expect(index.maskLayerIds).toEqual(new Set(["mask", "nested-mask"])); + expect(index.maskLayerIdsByArtboardId.get("a1")).toEqual(new Set(["mask", "nested-mask"])); + expect(index.maskLayerIdsByLayerList.get(document.artboards[0]!.layers)).toEqual(new Set(["mask", "nested-mask"])); + expect(index.maskLayerIdsByLayerList.get(groupLayer(document, "group").children)).toEqual(new Set(["nested-mask"])); + expect(index.displayLayerCountByArtboardId.get("a1")).toBe(3); + }); + + test("resolves layer bounds from indexed assets without scanning the document", () => { + const index = createDocumentReadIndex(document); + + expect(resolveIndexedLayerBounds(index, "target")).toEqual({ x: 10, y: 20, w: 50, h: 25 }); + expect(resolveIndexedLayerBounds(index, "group")).toEqual({ x: 0, y: 0, w: 120, h: 50 }); + expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined(); + }); + + test("visits layers back to front without mutating source order", () => { + const layers = document.artboards[0]!.layers; + const visited: string[] = []; + + forEachLayerBackToFront(layers, (layer) => visited.push(layer.id)); + + expect(visited).toEqual(["group", "target", "mask"]); + expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]); + }); +}); + +function raster( + id: string, + name: string, + assetId: string, + position = { x: 0, y: 0 }, + scale = { x: 1, y: 1 }, +): Extract { + return { + id, + type: "raster", + name, + visible: true, + locked: false, + opacity: 1, + assetId, + transform: { position, scale, rotation: 0 }, + }; +} + +function group(id: string, name: string, children: Layer[]): Extract { + return { + id, + type: "group", + name, + visible: true, + locked: false, + opacity: 1, + transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, + children, + }; +} + +function groupLayer(document: ImageDocument, id: string): Extract { + const layer = document.artboards[0]!.layers.find((candidate) => candidate.id === id); + if (!layer || layer.type !== "group") throw new Error(`Missing group ${id}`); + return layer; +} diff --git a/editor/document-indexes.ts b/editor/document-indexes.ts new file mode 100644 index 0000000..da7e966 --- /dev/null +++ b/editor/document-indexes.ts @@ -0,0 +1,164 @@ +import type { Asset } from "@core/asset"; +import type { ImageDocument } from "@core/document"; +import type { Rect } from "@core/geometry"; +import type { ArtboardId, AssetId, LayerId } from "@core/id"; +import type { Layer } from "@core/layer"; + +export type IndexedLayerInfo = { + artboardId: ArtboardId; + parentGroupId?: LayerId; + layer: Layer; + siblings: readonly Layer[]; + index: number; +}; + +export type DocumentReadIndex = { + assetById: ReadonlyMap; + layerById: ReadonlyMap; + layerInfoById: ReadonlyMap; + maskLayerIds: ReadonlySet; + maskLayerIdsByArtboardId: ReadonlyMap>; + maskLayerIdsByLayerList: ReadonlyMap>; + displayLayerCountByArtboardId: ReadonlyMap; +}; + +export function createDocumentReadIndex(document: ImageDocument): DocumentReadIndex { + const assetById = new Map(); + const layerById = new Map(); + const layerInfoById = new Map(); + const maskLayerIds = new Set(); + const maskLayerIdsByArtboardId = new Map>(); + const maskLayerIdsByLayerList = new Map>(); + const displayLayerCountByArtboardId = new Map(); + + for (const asset of document.assets) assetById.set(asset.id, asset); + + for (const artboard of document.artboards) { + const artboardMaskLayerIds = indexLayerTree({ + layers: artboard.layers, + artboardId: artboard.id, + layerById, + layerInfoById, + documentMaskLayerIds: maskLayerIds, + maskLayerIdsByLayerList, + }); + maskLayerIdsByArtboardId.set(artboard.id, artboardMaskLayerIds); + } + + for (const artboard of document.artboards) { + displayLayerCountByArtboardId.set(artboard.id, countDisplayLayers(artboard.layers, maskLayerIds)); + } + + return { + assetById, + layerById, + layerInfoById, + maskLayerIds, + maskLayerIdsByArtboardId, + maskLayerIdsByLayerList, + displayLayerCountByArtboardId, + }; +} + +export function forEachLayerBackToFront(layers: readonly Layer[], visit: (layer: Layer) => void) { + for (let index = layers.length - 1; index >= 0; index -= 1) { + const layer = layers[index]; + if (layer) visit(layer); + } +} + +export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: Layer | LayerId): Rect | undefined { + const layer = typeof layerOrId === "string" ? index.layerById.get(layerOrId) : layerOrId; + if (!layer) return undefined; + + switch (layer.type) { + case "group": + return unionLayerBounds(index, layer.children); + case "image": + case "raster": { + const asset = index.assetById.get(layer.assetId); + if (!asset) return undefined; + + return { + x: layer.transform.position.x, + y: layer.transform.position.y, + w: asset.intrinsicSize.w * layer.transform.scale.x, + h: asset.intrinsicSize.h * layer.transform.scale.y, + }; + } + } +} + +function indexLayerTree(options: { + layers: readonly Layer[]; + artboardId: ArtboardId; + parentGroupId?: LayerId; + layerById: Map; + layerInfoById: Map; + documentMaskLayerIds: Set; + maskLayerIdsByLayerList: Map>; +}): Set { + const layerListMaskLayerIds = new Set(); + + for (let index = 0; index < options.layers.length; index += 1) { + const layer = options.layers[index]; + if (!layer) continue; + + options.layerById.set(layer.id, layer); + options.layerInfoById.set(layer.id, { + artboardId: options.artboardId, + parentGroupId: options.parentGroupId, + layer, + siblings: options.layers, + index, + }); + + if (layer.clippingMask) { + options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId); + layerListMaskLayerIds.add(layer.clippingMask.maskLayerId); + } + + if (layer.type === "group") { + const childMaskLayerIds = indexLayerTree({ + ...options, + layers: layer.children, + parentGroupId: layer.id, + }); + for (const maskLayerId of childMaskLayerIds) layerListMaskLayerIds.add(maskLayerId); + } + } + + options.maskLayerIdsByLayerList.set(options.layers, layerListMaskLayerIds); + return layerListMaskLayerIds; +} + +function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet): number { + let count = 0; + for (const layer of layers) { + if (maskLayerIds.has(layer.id)) continue; + count += 1; + if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds); + } + return count; +} + +function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined { + let bounds: Rect | undefined; + + for (const layer of layers) { + const layerBounds = resolveIndexedLayerBounds(index, layer); + if (!layerBounds) continue; + bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds; + } + + return bounds; +} + +function unionRects(a: Rect, b: Rect): Rect { + const minX = Math.min(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxX = Math.max(a.x + a.w, b.x + b.w); + const maxY = Math.max(a.y + a.h, b.y + b.h); + + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} diff --git a/editor/index.ts b/editor/index.ts index 0f2d203..4277029 100644 --- a/editor/index.ts +++ b/editor/index.ts @@ -4,3 +4,5 @@ export { initialToolState } from "./tools"; export { createInitialAppState, initialEditorState } from "./initial-state"; export type { AppStore, StateListener } from "./store"; export { createAppStore } from "./store"; +export type { DocumentReadIndex, IndexedLayerInfo } from "./document-indexes"; +export { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes"; diff --git a/renderer/layers.ts b/renderer/layers.ts index 039df1a..8093b93 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -1,7 +1,7 @@ import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; +import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes"; import type { EditorState, MaskViewMode, ViewportState } from "@editor/state"; -import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { clearScreenRect } from "./clear-rect"; import type { ImageTextureRenderer } from "./image-textures"; import { documentRectToScreenRect } from "./screen-rect"; @@ -13,17 +13,19 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45]; const maskRevealPreviewOpacity = 0.28; export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) { + const documentIndex = createDocumentReadIndex(document); + for (const artboard of document.artboards) { if (!artboard.visible) continue; const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport); - const maskLayerIds = collectMaskLayerIds(artboard.layers); - for (const layer of renderStack(artboard.layers)) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds); + const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds; + forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds)); } } function renderLayer( context: WebGlRendererContext, - document: ImageDocument, + documentIndex: DocumentReadIndex, editor: EditorState, layer: Layer, imageTextureRenderer: ImageTextureRenderer, @@ -35,24 +37,24 @@ function renderLayer( const isolatedMaskView = isIsolatedMaskView(maskViewMode); if (!layer.visible || maskLayerIds.has(layer.id)) return; - const effectiveClipRect = resolveLayerClipRect(context, document, editor.viewport, layer, clipRect); + const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect); if (!effectiveClipRect) return; if (layer.type === "group") { - for (const child of renderStack(layer.children)) renderLayer(context, document, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds); + forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds)); return; } if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) return; - const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + const bounds = resolveIndexedLayerBounds(documentIndex, layer); if (!bounds) return; const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); - const asset = assetWithBrushStrokePreview(document.assets.find((candidate) => candidate.id === layer.assetId), editor); - const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined; - const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined, editor); - const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined; + const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor); + const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined; + const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor); + const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined; const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined; const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId); const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite"; @@ -79,20 +81,18 @@ function renderLayer( if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); } -function renderStack(layers: readonly Layer[]) { - return [...layers].reverse(); -} +const emptyLayerIds = new Set(); function resolveLayerClipRect( context: WebGlRendererContext, - document: ImageDocument, + documentIndex: DocumentReadIndex, viewport: ViewportState, layer: Layer, clipRect: ScreenRect, ): ScreenRect | undefined { if (!layer.clippingMask) return clipRect; - const maskBounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.clippingMask.maskLayerId }); + const maskBounds = resolveIndexedLayerBounds(documentIndex, layer.clippingMask.maskLayerId); if (!maskBounds) return clipRect; return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); @@ -107,33 +107,6 @@ function assetWithBrushStrokePreview()) { - for (const layer of layers) { - if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); - if (layer.type === "group") collectMaskLayerIds(layer.children, ids); - } - return ids; -} - function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined { const x1 = Math.max(a.x, b.x); const y1 = Math.max(a.y, b.y); diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index 8a2ef38..28cbb9a 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -1,13 +1,13 @@ -import { useRef, useState, type DragEvent, type MutableRefObject } from "react"; +import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react"; import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import type { ArtboardId } from "@core/id"; +import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; -import { resolveTransformTargetBounds } from "@editor/transform-targets"; -import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index"; +import { resolveLayerDrop } from "@input/index"; import { downloadArtboardPng } from "./exportArtboardPng"; export type LayersSheetProps = { @@ -19,7 +19,7 @@ export type LayersSheetProps = { }; export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) { - const draggedLayerId = useRef(); + const draggedLayerId = useRef(undefined); const [editingTitle, setEditingTitle] = useState(); return ( @@ -57,11 +57,13 @@ function LayersSheetBody({ editingTitle: EditingTitle | undefined; setEditingTitle: (editingTitle: EditingTitle | undefined) => void; }) { + const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]); const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id; - const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]); + const selectedLayerId = selection.layerIds[0]; + const selectedLayer = selectedLayerId ? documentIndex.layerInfoById.get(selectedLayerId) : undefined; const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); const canUngroup = selectedLayer?.layer.type === "group"; - const maskLayerIds = collectDocumentMaskLayerIds(document); + const maskLayerIds = documentIndex.maskLayerIds; return ( <> @@ -87,10 +89,10 @@ function LayersSheetBody({ - -
- {document.artboards.map((artboard) => ( + {document.artboards.map((artboard) => { + const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0; + + return (
- {countDisplayLayers(artboard.layers, maskLayerIds)} + {displayLayerCount}
- {countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? ( + {displayLayerCount === 0 ? (
No layers yet
) : ( artboard.layers.map((layer) => (
- ))} + ); + })}
); @@ -182,6 +189,7 @@ function LayersSheetBody({ function LayerRow({ document, + documentIndex, artboardId, layer, depth, @@ -194,6 +202,7 @@ function LayerRow({ dispatch, }: { document: ImageDocument; + documentIndex: DocumentReadIndex; artboardId: ArtboardId; layer: Layer; depth: number; @@ -208,8 +217,8 @@ function LayerRow({ if (maskLayerIds.has(layer.id)) return null; const selected = selectedLayerIds.includes(layer.id); - const layerInfo = findLayerInfoInDocument(document, layer.id); - const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined; + const layerInfo = documentIndex.layerInfoById.get(layer.id); + const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined; const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask); const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId); const rowPadding = 12 + depth * 16; @@ -269,7 +278,7 @@ function LayerRow({ @@ -309,6 +318,7 @@ function LayerRow({ candidate.id === layer.assetId); - const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + const asset = documentIndex.assetById.get(layer.assetId); + const bounds = resolveIndexedLayerBounds(documentIndex, layer); if (!asset || !bounds) return; const assetId = crypto.randomUUID(); @@ -390,30 +400,6 @@ function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: A }); } -function collectDocumentMaskLayerIds(document: ImageDocument): Set { - const ids = new Set(); - for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids); - return ids; -} - -function collectMaskLayerIds(layers: readonly Layer[], ids: Set): Set { - for (const layer of layers) { - if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); - if (layer.type === "group") collectMaskLayerIds(layer.children, ids); - } - return ids; -} - -function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet): number { - let count = 0; - for (const layer of layers) { - if (maskLayerIds.has(layer.id)) continue; - count += 1; - if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds); - } - return count; -} - function dropLayer( document: ImageDocument, sourceLayerId: string, @@ -431,7 +417,7 @@ function dropLayer( if (command) dispatch(commandIds.documentMoveLayer, command); } -function deleteSelection(selection: SelectionState, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) { +function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) { if (selectedLayer) { dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id }); return; @@ -448,7 +434,7 @@ function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) { }); } -function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) { +function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) { const artboard = document.artboards.find((candidate) => candidate.id === artboardId); if (!artboard) return; @@ -492,11 +478,9 @@ function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: Ap dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") }); } -function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) { - const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers; - if (!siblings) return; - - const maskLayerIds = collectMaskLayerIds(siblings, new Set()); +function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) { + const siblings = info.siblings; + const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds; const blocks = siblings.flatMap((layer, index) => { if (maskLayerIds.has(layer.id)) return []; @@ -522,7 +506,9 @@ function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, }); } -function createGroup(name: string): Layer { +const emptyLayerIds = new Set(); + +function createGroup(name: string): Extract { return { id: crypto.randomUUID(), type: "group",