import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react"; import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, 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 { ColorAdjustment } from "@core/adjustment-layer"; import { getLayerMask } from "@core/layer-mask-utils"; import type { ArtboardId } from "@core/id"; import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes"; import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; import { resolveLayerDrop } from "@input/index"; import type { DocumentActions } from "@app/document-actions"; import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions"; import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls"; import { LayerThumbnail } from "./layers/LayerThumbnail"; import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel"; export type LayersSheetProps = { document: ImageDocument; selection: SelectionState; maskEdit?: MaskEditState; open: boolean; dispatch: AppStore["dispatch"]; documentActions: DocumentActions; }; export function LayersSheet({ document, selection, maskEdit, open, dispatch, documentActions }: LayersSheetProps) { const draggedLayerId = useRef(undefined); const [editingTitle, setEditingTitle] = useState(); return ( ); } function LayersSheetBody({ document, selection, maskEdit, draggedLayerId, editingTitle, setEditingTitle, dispatch, documentActions, }: Omit & { draggedLayerId: MutableRefObject; editingTitle: EditingTitle | undefined; setEditingTitle: (editingTitle: EditingTitle | undefined) => void; }) { const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]); const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id; const selectedLayerId = selection.layerIds[0]; const selectedLayer = selectedLayerId ? documentIndex.layerInfoById.get(selectedLayerId) : undefined; const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); const canUngroup = selectedLayer?.layer.type === "group"; const maskLayerIds = documentIndex.maskLayerIds; const thumbnailByLayerId = useMemo(() => createLayerThumbnailIndex(document, documentIndex.assetById, maskLayerIds), [document, documentIndex, maskLayerIds]); return ( <>
{selectedLayer?.layer.type === "adjustment" ? : null}
{document.artboards.map((artboard) => { const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0; return (
{ if (draggedLayerId.current) event.preventDefault(); }} onDrop={(event) => { event.preventDefault(); if (draggedLayerId.current) dispatch(commandIds.documentMoveLayer, { layerId: draggedLayerId.current, toArtboardId: artboard.id, toIndex: artboard.layers.length }); draggedLayerId.current = undefined; }} > {editingTitle?.type === "artboard" && editingTitle.id === artboard.id ? ( setEditingTitle({ ...editingTitle, draft })} onCancel={() => setEditingTitle(undefined)} onCommit={() => { dispatch(commandIds.documentRenameArtboard, { id: artboard.id, name: editingTitle.draft }); setEditingTitle(undefined); }} /> ) : ( )} {displayLayerCount}
{displayLayerCount === 0 ? (
No layers yet
) : ( artboard.layers.map((layer) => ( )) )}
); })}
); } function AdjustmentInspector({ layer, dispatch }: { layer: Extract; dispatch: AppStore["dispatch"] }) { const [draft, setDraft] = useState(() => cloneAdjustment(layer.adjustment)); useEffect(() => setDraft(cloneAdjustment(layer.adjustment)), [layer.id, layer.adjustment]); const fields = [ ["Brightness", "brightness", draft.brightness], ["Contrast", "contrast", draft.contrast], ["Saturation", "saturation", draft.saturation], ["Red", "red", draft.colorBalance.red], ["Green", "green", draft.colorBalance.green], ["Blue", "blue", draft.colorBalance.blue], ] as const; const commit = () => { if (JSON.stringify(draft) === JSON.stringify(layer.adjustment)) return; dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: draft }); }; return (

Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.

{fields.map(([label, key, value]) => ( ))}
); } function cloneAdjustment(adjustment: ColorAdjustment): ColorAdjustment { return { ...adjustment, colorBalance: { ...adjustment.colorBalance } }; } function withAdjustmentValue(adjustment: ColorAdjustment, key: "brightness" | "contrast" | "saturation" | "red" | "green" | "blue", value: number): ColorAdjustment { if (key === "brightness" || key === "contrast" || key === "saturation") return { ...adjustment, [key]: value }; return { ...adjustment, colorBalance: { ...adjustment.colorBalance, [key]: value } }; } function LayerRow({ document, documentIndex, artboardId, layer, depth, selectedLayerIds, draggedLayerId, editingTitle, setEditingTitle, maskLayerIds, maskEdit, thumbnailByLayerId, dispatch, }: { document: ImageDocument; documentIndex: DocumentReadIndex; artboardId: ArtboardId; layer: Layer; depth: number; selectedLayerIds: string[]; draggedLayerId: MutableRefObject; editingTitle: EditingTitle | undefined; setEditingTitle: (editingTitle: EditingTitle | undefined) => void; maskLayerIds: ReadonlySet; maskEdit?: MaskEditState; thumbnailByLayerId: ReadonlyMap; dispatch: AppStore["dispatch"]; }) { if (maskLayerIds.has(layer.id)) return null; const selected = selectedLayerIds.includes(layer.id); const layerInfo = documentIndex.layerInfoById.get(layer.id); const layerMask = getLayerMask(layer); const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined; const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined; const canAddMask = Boolean(layerInfo && layer.type !== "group" && layer.type !== "adjustment" && !layerMask); 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; 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; }} > {maskThumbnail ? ( ) : null} {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); }} /> ) : ( )} {layerMask ? ( Mask ) : canAddMask && layerInfo ? ( ) : null}
{layerMask ? (
) : 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 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 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"; } function maskActionButtonClass() { return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; }