perf(document): index layer read paths
This commit is contained in:
105
editor/document-indexes.test.ts
Normal file
105
editor/document-indexes.test.ts
Normal file
@@ -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<Layer, { type: "raster" }> {
|
||||||
|
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<Layer, { type: "group" }> {
|
||||||
|
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<Layer, { type: "group" }> {
|
||||||
|
const layer = document.artboards[0]!.layers.find((candidate) => candidate.id === id);
|
||||||
|
if (!layer || layer.type !== "group") throw new Error(`Missing group ${id}`);
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
164
editor/document-indexes.ts
Normal file
164
editor/document-indexes.ts
Normal file
@@ -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<AssetId, Asset>;
|
||||||
|
layerById: ReadonlyMap<LayerId, Layer>;
|
||||||
|
layerInfoById: ReadonlyMap<LayerId, IndexedLayerInfo>;
|
||||||
|
maskLayerIds: ReadonlySet<LayerId>;
|
||||||
|
maskLayerIdsByArtboardId: ReadonlyMap<ArtboardId, ReadonlySet<LayerId>>;
|
||||||
|
maskLayerIdsByLayerList: ReadonlyMap<readonly Layer[], ReadonlySet<LayerId>>;
|
||||||
|
displayLayerCountByArtboardId: ReadonlyMap<ArtboardId, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDocumentReadIndex(document: ImageDocument): DocumentReadIndex {
|
||||||
|
const assetById = new Map<AssetId, Asset>();
|
||||||
|
const layerById = new Map<LayerId, Layer>();
|
||||||
|
const layerInfoById = new Map<LayerId, IndexedLayerInfo>();
|
||||||
|
const maskLayerIds = new Set<LayerId>();
|
||||||
|
const maskLayerIdsByArtboardId = new Map<ArtboardId, ReadonlySet<LayerId>>();
|
||||||
|
const maskLayerIdsByLayerList = new Map<readonly Layer[], ReadonlySet<LayerId>>();
|
||||||
|
const displayLayerCountByArtboardId = new Map<ArtboardId, number>();
|
||||||
|
|
||||||
|
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<LayerId, Layer>;
|
||||||
|
layerInfoById: Map<LayerId, IndexedLayerInfo>;
|
||||||
|
documentMaskLayerIds: Set<LayerId>;
|
||||||
|
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
|
||||||
|
}): Set<LayerId> {
|
||||||
|
const layerListMaskLayerIds = new Set<LayerId>();
|
||||||
|
|
||||||
|
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<LayerId>): 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 };
|
||||||
|
}
|
||||||
@@ -4,3 +4,5 @@ export { initialToolState } from "./tools";
|
|||||||
export { createInitialAppState, initialEditorState } from "./initial-state";
|
export { createInitialAppState, initialEditorState } from "./initial-state";
|
||||||
export type { AppStore, StateListener } from "./store";
|
export type { AppStore, StateListener } from "./store";
|
||||||
export { createAppStore } from "./store";
|
export { createAppStore } from "./store";
|
||||||
|
export type { DocumentReadIndex, IndexedLayerInfo } from "./document-indexes";
|
||||||
|
export { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
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 type { EditorState, MaskViewMode, ViewportState } from "@editor/state";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
|
||||||
import { clearScreenRect } from "./clear-rect";
|
import { clearScreenRect } from "./clear-rect";
|
||||||
import type { ImageTextureRenderer } from "./image-textures";
|
import type { ImageTextureRenderer } from "./image-textures";
|
||||||
import { documentRectToScreenRect } from "./screen-rect";
|
import { documentRectToScreenRect } from "./screen-rect";
|
||||||
@@ -13,17 +13,19 @@ const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
|
|||||||
const maskRevealPreviewOpacity = 0.28;
|
const maskRevealPreviewOpacity = 0.28;
|
||||||
|
|
||||||
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
|
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
|
||||||
|
const documentIndex = createDocumentReadIndex(document);
|
||||||
|
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
if (!artboard.visible) continue;
|
if (!artboard.visible) continue;
|
||||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
||||||
const maskLayerIds = collectMaskLayerIds(artboard.layers);
|
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
|
||||||
for (const layer of renderStack(artboard.layers)) renderLayer(context, document, editor, layer, imageTextureRenderer, clipRect, maskLayerIds);
|
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayer(
|
function renderLayer(
|
||||||
context: WebGlRendererContext,
|
context: WebGlRendererContext,
|
||||||
document: ImageDocument,
|
documentIndex: DocumentReadIndex,
|
||||||
editor: EditorState,
|
editor: EditorState,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
imageTextureRenderer: ImageTextureRenderer,
|
imageTextureRenderer: ImageTextureRenderer,
|
||||||
@@ -35,24 +37,24 @@ function renderLayer(
|
|||||||
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
||||||
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
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 (!effectiveClipRect) return;
|
||||||
|
|
||||||
if (layer.type === "group") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isolatedMaskView && layer.id !== editor.maskEdit?.targetLayerId) 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;
|
if (!bounds) return;
|
||||||
|
|
||||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
||||||
const asset = assetWithBrushStrokePreview(document.assets.find((candidate) => candidate.id === layer.assetId), editor);
|
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
|
||||||
const maskLayer = !editingMaskLayer && layer.clippingMask ? findLayer(document, layer.clippingMask.maskLayerId) : undefined;
|
const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
|
||||||
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined, editor);
|
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
|
||||||
const maskBounds = maskLayer ? resolveTransformTargetBounds(document, { type: "layer", id: maskLayer.id }) : undefined;
|
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
|
||||||
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : 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 activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId);
|
||||||
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
||||||
@@ -79,20 +81,18 @@ function renderLayer(
|
|||||||
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStack(layers: readonly Layer[]) {
|
const emptyLayerIds = new Set<string>();
|
||||||
return [...layers].reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLayerClipRect(
|
function resolveLayerClipRect(
|
||||||
context: WebGlRendererContext,
|
context: WebGlRendererContext,
|
||||||
document: ImageDocument,
|
documentIndex: DocumentReadIndex,
|
||||||
viewport: ViewportState,
|
viewport: ViewportState,
|
||||||
layer: Layer,
|
layer: Layer,
|
||||||
clipRect: ScreenRect,
|
clipRect: ScreenRect,
|
||||||
): ScreenRect | undefined {
|
): ScreenRect | undefined {
|
||||||
if (!layer.clippingMask) return clipRect;
|
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;
|
if (!maskBounds) return clipRect;
|
||||||
|
|
||||||
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
|
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
|
||||||
@@ -107,33 +107,6 @@ function assetWithBrushStrokePreview<TAsset extends ImageDocument["assets"][numb
|
|||||||
return { ...asset, source: editor.brushStrokePreview.source } as TAsset;
|
return { ...asset, source: editor.brushStrokePreview.source } as TAsset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLayer(document: ImageDocument, layerId: string): Layer | undefined {
|
|
||||||
for (const artboard of document.artboards) {
|
|
||||||
const layer = findLayerInTree(artboard.layers, layerId);
|
|
||||||
if (layer) return layer;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayerInTree(layers: readonly Layer[], layerId: string): Layer | undefined {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.id === layerId) return layer;
|
|
||||||
if (layer.type === "group") {
|
|
||||||
const child = findLayerInTree(layer.children, layerId);
|
|
||||||
if (child) return child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
|
|
||||||
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 {
|
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
||||||
const x1 = Math.max(a.x, b.x);
|
const x1 = Math.max(a.x, b.x);
|
||||||
const y1 = Math.max(a.y, b.y);
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
|||||||
@@ -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 { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
import type { ArtboardId } from "@core/id";
|
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 { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
import { resolveLayerDrop } from "@input/index";
|
||||||
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
|
|
||||||
import { downloadArtboardPng } from "./exportArtboardPng";
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
||||||
|
|
||||||
export type LayersSheetProps = {
|
export type LayersSheetProps = {
|
||||||
@@ -19,7 +19,7 @@ export type LayersSheetProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
||||||
const draggedLayerId = useRef<string>();
|
const draggedLayerId = useRef<string | undefined>(undefined);
|
||||||
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,11 +57,13 @@ function LayersSheetBody({
|
|||||||
editingTitle: EditingTitle | undefined;
|
editingTitle: EditingTitle | undefined;
|
||||||
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
||||||
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
|
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 canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
|
||||||
const canUngroup = selectedLayer?.layer.type === "group";
|
const canUngroup = selectedLayer?.layer.type === "group";
|
||||||
const maskLayerIds = collectDocumentMaskLayerIds(document);
|
const maskLayerIds = documentIndex.maskLayerIds;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -87,10 +89,10 @@ function LayersSheetBody({
|
|||||||
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
|
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
|
||||||
<Stack size={24} weight="fill" />
|
<Stack size={24} weight="fill" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={toolbarButtonClass()} aria-label="Move layer up" title="Move layer up" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
|
<button type="button" className={toolbarButtonClass()} aria-label="Move layer up" title="Move layer up" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(documentIndex, selectedLayer, -1, dispatch)}>
|
||||||
<ArrowUp size={24} />
|
<ArrowUp size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={toolbarButtonClass()} aria-label="Move layer down" title="Move layer down" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
|
<button type="button" className={toolbarButtonClass()} aria-label="Move layer down" title="Move layer down" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(documentIndex, selectedLayer, 1, dispatch)}>
|
||||||
<ArrowDown size={24} />
|
<ArrowDown size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={toolbarButtonClass()} aria-label="Delete selection" title="Delete selection" disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
|
<button type="button" className={toolbarButtonClass()} aria-label="Delete selection" title="Delete selection" disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
|
||||||
@@ -98,7 +100,10 @@ function LayersSheetBody({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-auto pb-2">
|
<div className="min-h-0 flex-1 overflow-auto pb-2">
|
||||||
{document.artboards.map((artboard) => (
|
{document.artboards.map((artboard) => {
|
||||||
|
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<section key={artboard.id} className="mb-5 last:mb-0">
|
<section key={artboard.id} className="mb-5 last:mb-0">
|
||||||
<div
|
<div
|
||||||
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
|
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
|
||||||
@@ -149,16 +154,17 @@ function LayersSheetBody({
|
|||||||
>
|
>
|
||||||
<DownloadSimple size={24} weight="regular" />
|
<DownloadSimple size={24} weight="regular" />
|
||||||
</button>
|
</button>
|
||||||
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{countDisplayLayers(artboard.layers, maskLayerIds)}</span>
|
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{displayLayerCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 space-y-2 pl-5">
|
<div className="mt-2 space-y-2 pl-5">
|
||||||
{countDisplayLayers(artboard.layers, maskLayerIds) === 0 ? (
|
{displayLayerCount === 0 ? (
|
||||||
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
|
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
|
||||||
) : (
|
) : (
|
||||||
artboard.layers.map((layer) => (
|
artboard.layers.map((layer) => (
|
||||||
<LayerRow
|
<LayerRow
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
document={document}
|
document={document}
|
||||||
|
documentIndex={documentIndex}
|
||||||
artboardId={artboard.id}
|
artboardId={artboard.id}
|
||||||
layer={layer}
|
layer={layer}
|
||||||
depth={0}
|
depth={0}
|
||||||
@@ -174,7 +180,8 @@ function LayersSheetBody({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -182,6 +189,7 @@ function LayersSheetBody({
|
|||||||
|
|
||||||
function LayerRow({
|
function LayerRow({
|
||||||
document,
|
document,
|
||||||
|
documentIndex,
|
||||||
artboardId,
|
artboardId,
|
||||||
layer,
|
layer,
|
||||||
depth,
|
depth,
|
||||||
@@ -194,6 +202,7 @@ function LayerRow({
|
|||||||
dispatch,
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
|
documentIndex: DocumentReadIndex;
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
layer: Layer;
|
layer: Layer;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -208,8 +217,8 @@ function LayerRow({
|
|||||||
if (maskLayerIds.has(layer.id)) return null;
|
if (maskLayerIds.has(layer.id)) return null;
|
||||||
|
|
||||||
const selected = selectedLayerIds.includes(layer.id);
|
const selected = selectedLayerIds.includes(layer.id);
|
||||||
const layerInfo = findLayerInfoInDocument(document, layer.id);
|
const layerInfo = documentIndex.layerInfoById.get(layer.id);
|
||||||
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
|
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
|
||||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
|
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 editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
|
||||||
const rowPadding = 12 + depth * 16;
|
const rowPadding = 12 + depth * 16;
|
||||||
@@ -269,7 +278,7 @@ function LayerRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
|
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
|
||||||
onClick={() => addLayerMask(document, layerInfo, dispatch)}
|
onClick={() => addLayerMask(documentIndex, layerInfo, dispatch)}
|
||||||
>
|
>
|
||||||
Add mask
|
Add mask
|
||||||
</button>
|
</button>
|
||||||
@@ -309,6 +318,7 @@ function LayerRow({
|
|||||||
<LayerRow
|
<LayerRow
|
||||||
key={child.id}
|
key={child.id}
|
||||||
document={document}
|
document={document}
|
||||||
|
documentIndex={documentIndex}
|
||||||
artboardId={artboardId}
|
artboardId={artboardId}
|
||||||
layer={child}
|
layer={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
@@ -350,12 +360,12 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: AppStore["dispatch"]) {
|
function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
||||||
const layer = layerInfo.layer;
|
const layer = layerInfo.layer;
|
||||||
if (layer.type === "group") return;
|
if (layer.type === "group") return;
|
||||||
|
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = documentIndex.assetById.get(layer.assetId);
|
||||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||||
if (!asset || !bounds) return;
|
if (!asset || !bounds) return;
|
||||||
|
|
||||||
const assetId = crypto.randomUUID();
|
const assetId = crypto.randomUUID();
|
||||||
@@ -390,30 +400,6 @@ function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: A
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDocumentMaskLayerIds(document: ImageDocument): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectMaskLayerIds(layers: readonly Layer[], ids: Set<string>): Set<string> {
|
|
||||||
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<string>): 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(
|
function dropLayer(
|
||||||
document: ImageDocument,
|
document: ImageDocument,
|
||||||
sourceLayerId: string,
|
sourceLayerId: string,
|
||||||
@@ -431,7 +417,7 @@ function dropLayer(
|
|||||||
if (command) dispatch(commandIds.documentMoveLayer, command);
|
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) {
|
if (selectedLayer) {
|
||||||
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
||||||
return;
|
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);
|
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||||
if (!artboard) return;
|
if (!artboard) return;
|
||||||
|
|
||||||
@@ -492,11 +478,9 @@ function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: Ap
|
|||||||
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
||||||
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
|
const siblings = info.siblings;
|
||||||
if (!siblings) return;
|
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
|
||||||
|
|
||||||
const maskLayerIds = collectMaskLayerIds(siblings, new Set<string>());
|
|
||||||
const blocks = siblings.flatMap((layer, index) => {
|
const blocks = siblings.flatMap((layer, index) => {
|
||||||
if (maskLayerIds.has(layer.id)) return [];
|
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<string>();
|
||||||
|
|
||||||
|
function createGroup(name: string): Extract<Layer, { type: "group" }> {
|
||||||
return {
|
return {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|||||||
Reference in New Issue
Block a user