115 lines
4.5 KiB
TypeScript
115 lines
4.5 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: "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 !== "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; }
|
|
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") 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;
|
|
}
|