- Introduced `getLayerMask` utility to streamline layer mask retrieval. - Updated layer rendering logic to incorporate layer masks and opacity adjustments. - Added functionality to prevent dropping a group into its descendants. - Enhanced image texture rendering to support opacity parameters across various rendering functions. - Implemented group layer bounds application for better scaling and positioning. - Added tests to ensure correct behavior when handling layer masks and group layers. - Created new types for asset generation provenance to track generated assets more effectively.
650 lines
30 KiB
TypeScript
650 lines
30 KiB
TypeScript
import { useEffect, useMemo, 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 { Asset } from "@core/asset";
|
|
import type { ImageDocument } from "@core/document";
|
|
import type { Layer } from "@core/layer";
|
|
import { getLayerMask } from "@core/layer-mask-utils";
|
|
import type { ArtboardId } from "@core/id";
|
|
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
|
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
|
import type { AppStore } from "@editor/store";
|
|
import { resolveLayerDrop } from "@input/index";
|
|
import { downloadArtboardPng } from "./exportArtboardPng";
|
|
import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster";
|
|
|
|
export type LayersSheetProps = {
|
|
document: ImageDocument;
|
|
selection: SelectionState;
|
|
maskEdit?: MaskEditState;
|
|
open: boolean;
|
|
dispatch: AppStore["dispatch"];
|
|
};
|
|
|
|
export function LayersSheet({ document, selection, maskEdit, open, dispatch }: LayersSheetProps) {
|
|
const draggedLayerId = useRef<string | undefined>(undefined);
|
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
|
|
|
return (
|
|
<aside
|
|
aria-hidden={!open}
|
|
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}
|
|
/>
|
|
) : null}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function LayersSheetBody({
|
|
document,
|
|
selection,
|
|
maskEdit,
|
|
draggedLayerId,
|
|
editingTitle,
|
|
setEditingTitle,
|
|
dispatch,
|
|
}: 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;
|
|
|
|
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 && addLayer(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 && addGroup(selectedArtboardId, dispatch)}>
|
|
<FolderPlus 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 && groupSelection(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>
|
|
<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 downloadArtboardPng(artboard, document.assets);
|
|
}}
|
|
>
|
|
<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}
|
|
dispatch={dispatch}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function LayerRow({
|
|
document,
|
|
documentIndex,
|
|
artboardId,
|
|
layer,
|
|
depth,
|
|
selectedLayerIds,
|
|
draggedLayerId,
|
|
editingTitle,
|
|
setEditingTitle,
|
|
maskLayerIds,
|
|
maskEdit,
|
|
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;
|
|
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" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
|
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
|
|
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
|
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>
|
|
{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 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]">
|
|
<Stack size={24} weight="fill" className="shrink-0" />
|
|
<span className="min-w-28 flex-1 truncate">{maskLayer ? "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" ? (
|
|
<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}
|
|
dispatch={dispatch}
|
|
/>
|
|
))
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MaskStatus({ asset }: { asset: Asset }) {
|
|
const [analysis, setAnalysis] = useState<MaskAnalysis>();
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void analyzeMaskSource(asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h)
|
|
.then((nextAnalysis) => {
|
|
if (!cancelled) setAnalysis(nextAnalysis);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setAnalysis(undefined);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
|
|
|
|
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
|
|
return (
|
|
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
|
|
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
|
|
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
|
|
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function MaskOperationButtons({ maskLayerId, maskAsset, dispatch }: { maskLayerId: string; maskAsset: Asset; dispatch: AppStore["dispatch"] }) {
|
|
return (
|
|
<>
|
|
<MaskOperationButton label="Invert" title="Invert mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "invert" }} dispatch={dispatch} />
|
|
<MaskOperationButton label="White" title="Fill mask white" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "white" }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Black" title="Fill mask black" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "black" }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Clear" title="Clear mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "fill", fill: "clear" }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Feather" title="Feather mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "feather", radius: 3 }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Expand" title="Expand mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "expand", radius: 3 }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Contract" title="Contract mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "contract", radius: 3 }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Blur" title="Blur mask edge" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "blur", radius: 2 }} dispatch={dispatch} />
|
|
<MaskOperationButton label="Clean" title="Despeckle mask" maskLayerId={maskLayerId} maskAsset={maskAsset} operation={{ type: "despeckle", strength: 8 }} dispatch={dispatch} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MaskOperationButton({
|
|
label,
|
|
title,
|
|
maskLayerId,
|
|
maskAsset,
|
|
operation,
|
|
dispatch,
|
|
}: {
|
|
label: string;
|
|
title: string;
|
|
maskLayerId: string;
|
|
maskAsset: Asset;
|
|
operation: MaskRasterOperation;
|
|
dispatch: AppStore["dispatch"];
|
|
}) {
|
|
const [busy, setBusy] = useState(false);
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={maskActionButtonClass()}
|
|
disabled={busy}
|
|
title={title}
|
|
onClick={() => {
|
|
setBusy(true);
|
|
void applyMaskRasterOperation(maskAsset.source, maskAsset.intrinsicSize.w, maskAsset.intrinsicSize.h, operation)
|
|
.then((source) => dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId, source, mimeType: "image/png", operation }))
|
|
.finally(() => setBusy(false));
|
|
}}
|
|
>
|
|
{busy ? "..." : label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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
|
|
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 addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
|
const layer = layerInfo.layer;
|
|
if (layer.type === "group") return;
|
|
|
|
const asset = documentIndex.assetById.get(layer.assetId);
|
|
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
|
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(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
|
|
|
|
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 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 deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | 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: IndexedLayerInfo | 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(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
|
|
|
|
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(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
|
const siblings = info.siblings;
|
|
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
|
|
const blocks = siblings.flatMap((layer, index) => {
|
|
if (maskLayerIds.has(layer.id)) return [];
|
|
|
|
const layerMask = getLayerMask(layer);
|
|
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.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,
|
|
});
|
|
}
|
|
|
|
const emptyLayerIds = new Set<string>();
|
|
|
|
function createGroup(name: string): Extract<Layer, { type: "group" }> {
|
|
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";
|
|
}
|
|
|
|
function maskActionButtonClass() {
|
|
return "h-8 rounded-full bg-white/5 px-3 text-xs font-medium text-sky-100/65 transition hover:bg-white/10 hover:text-sky-50 disabled:pointer-events-none disabled:opacity-35";
|
|
}
|
|
|
|
function formatPercent(value: number) {
|
|
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
|
}
|