import { 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 { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import type { ArtboardId } from "@core/id"; import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index"; import { downloadArtboardPng } from "./exportArtboardPng"; export type LayersSheetProps = { document: ImageDocument; selection: SelectionState; maskEdit?: MaskEditState; open: boolean; dispatch: AppStore["dispatch"]; }; export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) { const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id; const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]); const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); const canUngroup = selectedLayer?.layer.type === "group"; const maskLayerIds = collectDocumentMaskLayerIds(document); const draggedLayerId = useRef(); const [editingTitle, setEditingTitle] = useState(); return ( ); } function LayerRow({ document, artboardId, layer, depth, selectedLayerIds, draggedLayerId, editingTitle, setEditingTitle, maskLayerIds, maskEdit, dispatch, }: { document: ImageDocument; artboardId: ArtboardId; layer: Layer; depth: number; selectedLayerIds: string[]; draggedLayerId: MutableRefObject; editingTitle: EditingTitle | undefined; setEditingTitle: (editingTitle: EditingTitle | undefined) => void; maskLayerIds: ReadonlySet; maskEdit?: MaskEditState; dispatch: AppStore["dispatch"]; }) { if (maskLayerIds.has(layer.id)) return null; const selected = selectedLayerIds.includes(layer.id); const layerInfo = findLayerInfoInDocument(document, layer.id); const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined; 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 rowPadding = 12 + depth * 16; return (
{ event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", layer.id); draggedLayerId.current = layer.id; }} onDragEnd={() => { draggedLayerId.current = undefined; }} onDragOver={(event) => { if (draggedLayerId.current && draggedLayerId.current !== layer.id) event.preventDefault(); }} onDrop={(event) => { event.preventDefault(); const sourceLayerId = draggedLayerId.current ?? event.dataTransfer.getData("text/plain"); if (sourceLayerId && sourceLayerId !== layer.id) dropLayer(document, sourceLayerId, { artboardId, layer }, event, dispatch); draggedLayerId.current = undefined; }} > {editingTitle?.type === "layer" && editingTitle.id === layer.id ? ( setEditingTitle({ ...editingTitle, draft })} onCancel={() => setEditingTitle(undefined)} onCommit={() => { dispatch(commandIds.documentRenameLayer, { layerId: layer.id, name: editingTitle.draft }); setEditingTitle(undefined); }} /> ) : ( )} {layer.clippingMask ? ( Mask ) : canAddMask && layerInfo ? ( ) : null}
{layer.clippingMask ? (
{maskLayer ? "Layer mask" : "Layer mask missing"} {maskLayer ? ( ) : null}
) : null} {layer.type === "group" ? layer.children.map((child) => ( )) : null}
); } type EditingTitle = | { type: "artboard"; id: ArtboardId; draft: string } | { type: "layer"; id: string; draft: string }; function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; onChange: (value: string) => void; onCommit: () => void; onCancel: () => void }) { return ( onChange(event.target.value)} onBlur={onCommit} onFocus={(event) => event.currentTarget.select()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") onCancel(); }} /> ); } function addLayerMask(document: ImageDocument, layerInfo: LayerInfo, dispatch: AppStore["dispatch"]) { const layer = layerInfo.layer; if (layer.type === "group") return; const asset = document.assets.find((candidate) => candidate.id === layer.assetId); const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); if (!asset || !bounds) return; const assetId = crypto.randomUUID(); const maskLayerId = crypto.randomUUID(); const width = Math.max(1, Math.round(asset.intrinsicSize.w)); const height = Math.max(1, Math.round(asset.intrinsicSize.h)); const source = `data:image/svg+xml,${encodeURIComponent(``)}`; dispatch(commandIds.documentAddLayerMask, { layerId: layer.id, asset: { id: assetId, name: `${layer.name} Mask`, mimeType: "image/svg+xml", source, intrinsicSize: { w: width, h: height }, }, maskLayer: { id: maskLayerId, type: "raster", name: `${layer.name} Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: bounds.x, y: bounds.y }, scale: { x: bounds.w / width, y: bounds.h / height }, rotation: layer.transform.rotation, }, }, }); } function collectDocumentMaskLayerIds(document: ImageDocument): Set { const ids = new Set(); for (const artboard of document.artboards) collectMaskLayerIds(artboard.layers, ids); return ids; } function collectMaskLayerIds(layers: readonly Layer[], ids: Set): Set { 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): 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( document: ImageDocument, sourceLayerId: string, target: { artboardId: ArtboardId; layer: Layer }, event: DragEvent, dispatch: AppStore["dispatch"], ) { const rect = event.currentTarget.getBoundingClientRect(); const command = resolveLayerDrop({ document, sourceLayerId, target, verticalRatio: (event.clientY - rect.top) / Math.max(1, rect.height), }); if (command) dispatch(commandIds.documentMoveLayer, command); } function deleteSelection(selection: SelectionState, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) { if (selectedLayer) { dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id }); return; } if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId }); } function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) { const index = document.artboards.length + 1; dispatch(commandIds.documentAddArtboard, { id: crypto.randomUUID(), name: `Artboard ${index}`, bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 }, }); } function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) { const artboard = document.artboards.find((candidate) => candidate.id === artboardId); if (!artboard) return; const assetId = crypto.randomUUID(); const layerId = crypto.randomUUID(); const width = Math.max(1, Math.round(artboard.bounds.w)); const height = Math.max(1, Math.round(artboard.bounds.h)); const source = `data:image/svg+xml,${encodeURIComponent(``)}`; dispatch(commandIds.documentAddAsset, { asset: { id: assetId, name: "Empty Layer", mimeType: "image/svg+xml", source, intrinsicSize: { w: width, h: height }, }, }); dispatch(commandIds.documentAddRasterLayer, { artboardId, parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined, layer: { id: layerId, type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 }, }, }); dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] }); } function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) { dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") }); } function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) { dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") }); } function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) { const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers; if (!siblings) return; const maskLayerIds = collectMaskLayerIds(siblings, new Set()); const blocks = siblings.flatMap((layer, index) => { if (maskLayerIds.has(layer.id)) return []; const maskIndex = layer.clippingMask ? siblings.findIndex((candidate) => candidate.id === layer.clippingMask?.maskLayerId) : -1; const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index; const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index; return [{ layerId: layer.id, start, end, size: end - start + 1 }]; }); const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id); const currentBlock = blocks[currentBlockIndex]; const targetBlock = blocks[currentBlockIndex + direction]; if (!currentBlock || !targetBlock) return; const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1; const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0; dispatch(commandIds.documentMoveLayer, { layerId: info.layer.id, toArtboardId: info.artboardId, toParentGroupId: info.parentGroupId, toIndex: insertionIndex - removedBeforeInsertion, }); } function createGroup(name: string): Layer { return { id: crypto.randomUUID(), type: "group", name, visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [], }; } function toolbarButtonClass() { return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"; } function labeledToolbarButtonClass() { return "inline-flex h-12 flex-1 items-center rounded-full pr-4 text-sm font-medium text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"; }