Files
image-studio/view/LayersSheet.tsx
2026-07-11 12:31:06 +02:00

508 lines
26 KiB
TypeScript

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<string | undefined>(undefined);
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
return (
<aside
id="layers-sheet"
aria-hidden={!open}
aria-label="Layers"
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
{open ? (
<LayersSheetBody
document={document}
selection={selection}
maskEdit={maskEdit}
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
dispatch={dispatch}
documentActions={documentActions}
/>
) : null}
</aside>
);
}
function LayersSheetBody({
document,
selection,
maskEdit,
draggedLayerId,
editingTitle,
setEditingTitle,
dispatch,
documentActions,
}: Omit<LayersSheetProps, "open"> & {
draggedLayerId: MutableRefObject<string | undefined>;
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 (
<>
<header className="flex h-20 items-center">
<div className="flex w-full items-center gap-2">
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Artboard</span>
</button>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addEmptyLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="min-w-0 flex-1 text-center">Layer</span>
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
<FolderPlus size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add color adjustment" title="Add non-destructive artboard color adjustment" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addAdjustmentLayer(selectedArtboardId, dispatch)}>
<SlidersHorizontal size={24} />
</button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupLayers(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
<Stack size={24} weight="fill" />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Move layer up" title="Move layer up" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(documentIndex, selectedLayer, -1, dispatch)}>
<ArrowUp size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Move layer down" title="Move layer down" disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(documentIndex, selectedLayer, 1, dispatch)}>
<ArrowDown size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Delete selection" title="Delete selection" disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
<Trash size={24} />
</button>
</div>
{selectedLayer?.layer.type === "adjustment" ? <AdjustmentInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
<div className="min-h-0 flex-1 overflow-auto pb-2">
{document.artboards.map((artboard) => {
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
return (
<section key={artboard.id} className="mb-5 last:mb-0">
<div
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
onDragOver={(event) => {
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;
}}
>
<button type="button" className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "text-black/55 transition hover:text-black" : "text-white/55 transition hover:text-white"} aria-label={artboard.visible ? "Hide artboard" : "Show artboard"} onClick={() => dispatch(commandIds.documentSetArtboardVisible, { id: artboard.id, visible: !artboard.visible })}>
{artboard.visible ? <Eye size={24} weight="regular" /> : <EyeSlash size={24} weight="regular" />}
</button>
<button type="button" className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "text-black/55 transition hover:text-black" : "text-white/55 transition hover:text-white"} aria-label={artboard.locked ? "Unlock artboard" : "Lock artboard"} onClick={() => dispatch(commandIds.documentSetArtboardLocked, { id: artboard.id, locked: !artboard.locked })}>
{artboard.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
</button>
{editingTitle?.type === "artboard" && editingTitle.id === artboard.id ? (
<RenameInput
value={editingTitle.draft}
onChange={(draft) => setEditingTitle({ ...editingTitle, draft })}
onCancel={() => setEditingTitle(undefined)}
onCommit={() => {
dispatch(commandIds.documentRenameArtboard, { id: artboard.id, name: editingTitle.draft });
setEditingTitle(undefined);
}}
/>
) : (
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-medium"
onClick={() => dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [] })}
onDoubleClick={() => setEditingTitle({ type: "artboard", id: artboard.id, draft: artboard.name })}
>
{artboard.name}
</button>
)}
<button
type="button"
className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "text-black/55 transition hover:text-black" : "text-white/55 transition hover:text-white"}
aria-label={`Export ${artboard.name} as PNG`}
title="Export PNG"
onClick={(event) => {
event.stopPropagation();
void documentActions.exportArtboard(artboard.id);
}}
>
<DownloadSimple size={24} weight="regular" />
</button>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{displayLayerCount}</span>
</div>
<div className="mt-2 space-y-2 pl-5">
{displayLayerCount === 0 ? (
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
) : (
artboard.layers.map((layer) => (
<LayerRow
key={layer.id}
document={document}
documentIndex={documentIndex}
artboardId={artboard.id}
layer={layer}
depth={0}
selectedLayerIds={selection.layerIds}
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
maskLayerIds={maskLayerIds}
maskEdit={maskEdit}
thumbnailByLayerId={thumbnailByLayerId}
dispatch={dispatch}
/>
))
)}
</div>
</section>
);
})}
</div>
</>
);
}
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
const [draft, setDraft] = useState<ColorAdjustment>(() => 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 (
<section aria-label="Adjustment settings" className="mb-3 rounded-[1.5rem] bg-white/[0.05] p-3">
<p className="mb-2 text-xs text-white/45">
Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.
</p>
<div className="grid grid-cols-2 gap-2">
{fields.map(([label, key, value]) => (
<label key={key} className="text-[0.7rem] text-white/55">
<span>{label}</span>
<input
className="mt-1 w-full accent-violet-300"
type="range"
min="-1"
max="1"
step="0.01"
value={value}
disabled={layer.locked}
onChange={(event) => setDraft(withAdjustmentValue(draft, key, Number(event.currentTarget.value)))}
onPointerUp={commit}
onBlur={commit}
/>
</label>
))}
</div>
</section>
);
}
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<string | undefined>;
editingTitle: EditingTitle | undefined;
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
maskLayerIds: ReadonlySet<string>;
maskEdit?: MaskEditState;
thumbnailByLayerId: ReadonlyMap<string, LayerThumbnailModel>;
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 (
<div>
<div
draggable
className={`group flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${editingMask ? "bg-sky-300 text-black" : selected ? "bg-white text-black" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: rowPadding }}
onDragStart={(event) => {
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;
}}
>
<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" />}
</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 ? (
<RenameInput
value={editingTitle.draft}
onChange={(draft) => setEditingTitle({ ...editingTitle, draft })}
onCancel={() => setEditingTitle(undefined)}
onCommit={() => {
dispatch(commandIds.documentRenameLayer, { layerId: layer.id, name: editingTitle.draft });
setEditingTitle(undefined);
}}
/>
) : (
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-medium"
onClick={() => dispatch(commandIds.selectionSet, { artboardId, layerIds: [layer.id] })}
onDoubleClick={() => setEditingTitle({ type: "layer", id: layer.id, draft: layer.name })}
>
{layer.name}
</button>
)}
{layerMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask
</span>
) : canAddMask && layerInfo ? (
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(documentIndex, layerInfo, dispatch)}
>
Add mask
</button>
) : null}
<button type="button" className={editingMask || selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
{layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
</button>
</div>
{layerMask ? (
<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]"}`}>
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-sky-300/40" />
{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}
{maskLayer ? (
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className={`h-9 rounded-full px-4 text-sm font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/75 hover:bg-white/10 hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId })
}
>
{editingMask ? "Done" : "Edit"}
</button>
<button
type="button"
className={maskActionButtonClass()}
title="Paint reveal"
onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "brush" });
}}
>
Reveal
</button>
<button
type="button"
className={maskActionButtonClass()}
title="Paint hide"
onClick={() => {
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
dispatch(commandIds.toolSetActive, { tool: "eraser" });
}}
>
Hide
</button>
{maskAsset && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? (
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
) : null}
</div>
) : null}
<button
type="button"
className="h-9 rounded-full px-4 text-sm font-medium text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
>
Remove
</button>
</div>
) : null}
{layer.type === "group"
? layer.children.map((child) => (
<LayerRow
key={child.id}
document={document}
documentIndex={documentIndex}
artboardId={artboardId}
layer={child}
depth={depth + 1}
selectedLayerIds={selectedLayerIds}
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
maskLayerIds={maskLayerIds}
maskEdit={maskEdit}
thumbnailByLayerId={thumbnailByLayerId}
dispatch={dispatch}
/>
))
: null}
</div>
);
}
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 (
<input
autoFocus
aria-label="Rename item"
className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
value={value}
onChange={(event) => 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<HTMLElement>,
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";
}