feat: add layer and mask thumbnails
This commit is contained in:
@@ -12,6 +12,8 @@ import { resolveLayerDrop } from "@input/index";
|
|||||||
import type { DocumentActions } from "@app/document-actions";
|
import type { DocumentActions } from "@app/document-actions";
|
||||||
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
|
||||||
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
|
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
|
||||||
|
import { LayerThumbnail } from "./layers/LayerThumbnail";
|
||||||
|
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
|
||||||
|
|
||||||
export type LayersSheetProps = {
|
export type LayersSheetProps = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
@@ -72,6 +74,7 @@ function LayersSheetBody({
|
|||||||
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 = documentIndex.maskLayerIds;
|
const maskLayerIds = documentIndex.maskLayerIds;
|
||||||
|
const thumbnailByLayerId = useMemo(() => createLayerThumbnailIndex(document, documentIndex.assetById, maskLayerIds), [document, documentIndex, maskLayerIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -182,6 +185,7 @@ function LayersSheetBody({
|
|||||||
setEditingTitle={setEditingTitle}
|
setEditingTitle={setEditingTitle}
|
||||||
maskLayerIds={maskLayerIds}
|
maskLayerIds={maskLayerIds}
|
||||||
maskEdit={maskEdit}
|
maskEdit={maskEdit}
|
||||||
|
thumbnailByLayerId={thumbnailByLayerId}
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -207,6 +211,7 @@ function LayerRow({
|
|||||||
setEditingTitle,
|
setEditingTitle,
|
||||||
maskLayerIds,
|
maskLayerIds,
|
||||||
maskEdit,
|
maskEdit,
|
||||||
|
thumbnailByLayerId,
|
||||||
dispatch,
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
@@ -220,6 +225,7 @@ function LayerRow({
|
|||||||
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||||
maskLayerIds: ReadonlySet<string>;
|
maskLayerIds: ReadonlySet<string>;
|
||||||
maskEdit?: MaskEditState;
|
maskEdit?: MaskEditState;
|
||||||
|
thumbnailByLayerId: ReadonlyMap<string, LayerThumbnailModel>;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
}) {
|
}) {
|
||||||
if (maskLayerIds.has(layer.id)) return null;
|
if (maskLayerIds.has(layer.id)) return null;
|
||||||
@@ -231,6 +237,8 @@ function LayerRow({
|
|||||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
|
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
|
||||||
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
||||||
|
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
|
||||||
|
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
|
||||||
const rowPadding = 12 + depth * 16;
|
const rowPadding = 12 + depth * 16;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -260,6 +268,14 @@ function LayerRow({
|
|||||||
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
|
<button type="button" className={editingMask || selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
|
||||||
{layer.visible ? <Eye size={24} weight="regular" /> : <EyeSlash size={24} weight="regular" />}
|
{layer.visible ? <Eye size={24} weight="regular" /> : <EyeSlash size={24} weight="regular" />}
|
||||||
</button>
|
</button>
|
||||||
|
<span className={`relative flex shrink-0 items-center ${layer.visible ? "" : "opacity-35 grayscale"}`}>
|
||||||
|
<LayerThumbnail model={thumbnail} label={`${layer.name} thumbnail`} />
|
||||||
|
{maskThumbnail ? (
|
||||||
|
<span className={`-ml-2 rounded-md ${editingMask ? "ring-2 ring-sky-500 ring-offset-2 ring-offset-sky-300" : "ring-1 ring-sky-300/70"}`}>
|
||||||
|
<LayerThumbnail model={maskThumbnail} label={`${layer.name} mask thumbnail${editingMask ? ", currently editing" : ""}`} compact />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
||||||
<RenameInput
|
<RenameInput
|
||||||
value={editingTitle.draft}
|
value={editingTitle.draft}
|
||||||
@@ -298,9 +314,10 @@ function LayerRow({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{layerMask ? (
|
{layerMask ? (
|
||||||
<div className="mt-2 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
|
<div role="group" aria-label={`Mask attached to ${layer.name}${editingMask ? ", currently editing" : ""}`} className={`relative mt-2 ml-10 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] border-l-2 py-2 pl-4 pr-2 text-sm transition ${editingMask ? "border-sky-300 bg-sky-300/15 text-sky-50 ring-1 ring-inset ring-sky-300/30" : "border-sky-300/30 text-sky-100/70 hover:bg-white/[0.04]"}`}>
|
||||||
<Stack size={24} weight="fill" className="shrink-0" />
|
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-sky-300/40" />
|
||||||
<span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
|
{maskThumbnail ? <LayerThumbnail model={maskThumbnail} label={`${layer.name} mask preview`} /> : <Stack size={24} weight="fill" className="shrink-0" />}
|
||||||
|
<span className="min-w-28 flex-1 truncate font-medium">{maskLayer ? editingMask ? "Editing layer mask" : "Layer mask" : "Layer mask missing"}</span>
|
||||||
{maskAsset ? <MaskStatus asset={maskAsset} /> : null}
|
{maskAsset ? <MaskStatus asset={maskAsset} /> : null}
|
||||||
{maskLayer ? (
|
{maskLayer ? (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
@@ -366,6 +383,7 @@ function LayerRow({
|
|||||||
setEditingTitle={setEditingTitle}
|
setEditingTitle={setEditingTitle}
|
||||||
maskLayerIds={maskLayerIds}
|
maskLayerIds={maskLayerIds}
|
||||||
maskEdit={maskEdit}
|
maskEdit={maskEdit}
|
||||||
|
thumbnailByLayerId={thumbnailByLayerId}
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
29
view/layers/LayerThumbnail.tsx
Normal file
29
view/layers/LayerThumbnail.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { FolderSimple, ImageBroken } from "@phosphor-icons/react";
|
||||||
|
import type { LayerThumbnailModel, RasterThumbnailModel } from "./thumbnailModel";
|
||||||
|
|
||||||
|
export function LayerThumbnail({ model, label, compact = false }: { model: LayerThumbnailModel; label: string; compact?: boolean }) {
|
||||||
|
const sizeClass = compact ? "h-7 w-9 rounded-md" : "h-8 w-10 rounded-lg";
|
||||||
|
return (
|
||||||
|
<span role="img" aria-label={label} className={`relative grid shrink-0 place-items-center overflow-hidden bg-black/25 ring-1 ring-inset ring-white/15 ${sizeClass}`}>
|
||||||
|
{model.kind === "raster" ? <RasterPreview model={model} /> : null}
|
||||||
|
{model.kind === "group" ? (
|
||||||
|
<>
|
||||||
|
<span className="absolute inset-1 grid grid-cols-2 gap-px overflow-hidden rounded">
|
||||||
|
{model.previews.map((preview, index) => <RasterPreview key={`${preview.source}-${index}`} model={preview} />)}
|
||||||
|
</span>
|
||||||
|
<FolderSimple size={compact ? 14 : 17} weight="fill" className="relative drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{model.kind === "empty" ? <ImageBroken size={compact ? 14 : 17} className="text-white/35" /> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RasterPreview({ model }: { model: RasterThumbnailModel }) {
|
||||||
|
const { x, y, w, h } = model.viewBox;
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="h-full w-full bg-[linear-gradient(45deg,rgba(255,255,255,.06)_25%,transparent_25%,transparent_75%,rgba(255,255,255,.06)_75%),linear-gradient(45deg,rgba(255,255,255,.06)_25%,transparent_25%,transparent_75%,rgba(255,255,255,.06)_75%)] bg-[length:8px_8px] bg-[position:0_0,4px_4px]" viewBox={`${x} ${y} ${w} ${h}`} preserveAspectRatio="xMidYMid meet">
|
||||||
|
<image href={model.source} x="0" y="0" width={model.intrinsicWidth} height={model.intrinsicHeight} onError={(event) => event.currentTarget.setAttribute("visibility", "hidden")} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
view/layers/thumbnailModel.test.ts
Normal file
53
view/layers/thumbnailModel.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { Asset } from "@core/asset";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
import { createLayerThumbnailIndex, resolveLayerThumbnail } from "./thumbnailModel";
|
||||||
|
|
||||||
|
const assets = new Map([
|
||||||
|
["asset-a", { id: "asset-a", name: "A", mimeType: "image/png", source: "data:image/png;base64,a", intrinsicSize: { w: 400, h: 200 } }],
|
||||||
|
["asset-b", { id: "asset-b", name: "B", mimeType: "image/webp", source: "blob:b", intrinsicSize: { w: 80, h: 60 } }],
|
||||||
|
] as const) as ReadonlyMap<string, Asset>;
|
||||||
|
|
||||||
|
describe("layer thumbnail read model", () => {
|
||||||
|
test("preserves and clamps a layer source crop", () => {
|
||||||
|
expect(resolveLayerThumbnail(raster("cropped", "asset-a", { x: 350, y: 20, w: 100, h: 240 }), assets)).toEqual({
|
||||||
|
kind: "raster",
|
||||||
|
source: "data:image/png;base64,a",
|
||||||
|
viewBox: { x: 350, y: 20, w: 50, h: 180 },
|
||||||
|
intrinsicWidth: 400,
|
||||||
|
intrinsicHeight: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a graceful empty model for invalid and missing assets", () => {
|
||||||
|
expect(resolveLayerThumbnail(raster("missing", "unknown"), assets)).toEqual({ kind: "empty" });
|
||||||
|
expect(resolveLayerThumbnail(raster("outside", "asset-a", { x: 500, y: 0, w: 20, h: 20 }), assets)).toEqual({ kind: "empty" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collects a bounded visible group preview and excludes attached masks", () => {
|
||||||
|
const group = base("group", "group") as Layer & { type: "group"; children: Layer[] };
|
||||||
|
group.type = "group";
|
||||||
|
group.children = [raster("hidden", "asset-a", undefined, false), raster("mask", "asset-a"), raster("one", "asset-a"), raster("two", "asset-b"), raster("three", "asset-a"), raster("four", "asset-a")];
|
||||||
|
const result = resolveLayerThumbnail(group, assets, new Set(["mask"]));
|
||||||
|
expect(result.kind).toBe("group");
|
||||||
|
if (result.kind === "group") expect(result.previews.map((preview) => preview.source)).toEqual(["data:image/png;base64,a", "blob:b", "data:image/png;base64,a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("indexes nested group previews once for row lookup", () => {
|
||||||
|
const child = { ...base("child-group", "Child"), type: "group" as const, children: [raster("nested", "asset-b")] };
|
||||||
|
const group = { ...base("root", "Root"), type: "group" as const, children: [child] };
|
||||||
|
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
|
||||||
|
const index = createLayerThumbnailIndex(document, assets);
|
||||||
|
expect(index.get("nested")?.kind).toBe("raster");
|
||||||
|
expect(index.get("child-group")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });
|
||||||
|
expect(index.get("root")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function raster(id: string, assetId: string, sourceRect?: { x: number; y: number; w: number; h: number }, visible = true): Layer {
|
||||||
|
return { ...base(id, id), type: "raster", assetId, visible, ...(sourceRect ? { sourceRect } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function base(id: string, name: string) {
|
||||||
|
return { id, name, visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } };
|
||||||
|
}
|
||||||
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