feat: add layer and mask thumbnails
This commit is contained in:
110
view/layers/thumbnailModel.ts
Normal file
110
view/layers/thumbnailModel.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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: "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 !== "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") {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user