Files
image-studio/view/layers/thumbnailModel.ts
2026-07-11 12:38:34 +02:00

118 lines
4.8 KiB
TypeScript

import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
export type RasterThumbnailModel = {
kind: "raster";
source: string;
viewBox: Rect;
intrinsicWidth: number;
intrinsicHeight: number;
};
export type LayerThumbnailModel =
| RasterThumbnailModel
| { kind: "adjustment" }
| { kind: "text"; content: string; color: string }
| { kind: "group"; previews: readonly RasterThumbnailModel[] }
| { kind: "empty" };
/** Builds a small, read-only preview descriptor without decoding or rerasterizing assets. */
export function resolveLayerThumbnail(
layer: Layer,
assetById: ReadonlyMap<AssetId, Asset>,
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
): LayerThumbnailModel {
if (layer.type === "adjustment") return { kind: "adjustment" };
if (layer.type === "text") return { kind: "text", content: layer.content, color: layer.style.color };
if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" };
const previews: RasterThumbnailModel[] = [];
const pending = [...layer.children].reverse();
while (pending.length > 0 && previews.length < 3) {
const child = pending.pop();
if (!child || excludedLayerIds.has(child.id) || !child.visible) continue;
if (child.type === "group") {
pending.push(...[...child.children].reverse());
continue;
}
const preview = resolveRasterThumbnail(child, assetById);
if (preview) previews.push(preview);
}
return { kind: "group", previews };
}
/** Resolves every layer once, including group mosaics, for linear tree rendering. */
export function createLayerThumbnailIndex(
document: ImageDocument,
assetById: ReadonlyMap<AssetId, Asset>,
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
): ReadonlyMap<LayerId, LayerThumbnailModel> {
const result = new Map<LayerId, LayerThumbnailModel>();
const pending: Array<{ layer: Layer; visited: boolean }> = [];
for (const artboard of document.artboards) for (const layer of artboard.layers) pending.push({ layer, visited: false });
while (pending.length > 0) {
const frame = pending.pop();
if (!frame) continue;
const { layer } = frame;
if (frame.visited || layer.type !== "group") {
if (layer.type !== "group") {
if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; }
if (layer.type === "text") { result.set(layer.id, { kind: "text", content: layer.content, color: layer.style.color }); continue; }
result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" });
continue;
}
const previews: RasterThumbnailModel[] = [];
for (const child of layer.children) {
if (previews.length >= 3) break;
if (excludedLayerIds.has(child.id) || !child.visible) continue;
const childModel = result.get(child.id);
if (childModel?.kind === "raster") previews.push(childModel);
if (childModel?.kind === "group") previews.push(...childModel.previews.slice(0, 3 - previews.length));
}
result.set(layer.id, { kind: "group", previews });
continue;
}
pending.push({ layer, visited: true });
for (let index = layer.children.length - 1; index >= 0; index -= 1) {
const child = layer.children[index];
if (child) pending.push({ layer: child, visited: false });
}
}
return result;
}
function resolveRasterThumbnail(layer: Exclude<Layer, { type: "group" }>, assetById: ReadonlyMap<AssetId, Asset>): RasterThumbnailModel | undefined {
if (layer.type === "adjustment" || layer.type === "text") return undefined;
const asset = assetById.get(layer.assetId);
if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined;
const fullRect = { x: 0, y: 0, w: asset.intrinsicSize.w, h: asset.intrinsicSize.h };
const viewBox = layer.sourceRect ? intersectRect(layer.sourceRect, fullRect) : fullRect;
if (!viewBox) return undefined;
return {
kind: "raster",
source: asset.source,
viewBox,
intrinsicWidth: asset.intrinsicSize.w,
intrinsicHeight: asset.intrinsicSize.h,
};
}
function intersectRect(rect: Rect, bounds: Rect): Rect | undefined {
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) || rect.w <= 0 || rect.h <= 0) return undefined;
const x = Math.max(rect.x, bounds.x);
const y = Math.max(rect.y, bounds.y);
const right = Math.min(rect.x + rect.w, bounds.x + bounds.w);
const bottom = Math.min(rect.y + rect.h, bounds.y + bounds.h);
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : undefined;
}
function positiveFinite(value: number) {
return Number.isFinite(value) && value > 0;
}