- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle. - Updated mask edit state to include mask asset ID and kind. - Implemented inpaint region commands for adding, applying, and removing inpaint regions. - Introduced new operations for lasso and semantic selection tools. - Created UI components for candidate review and inpaint region management. - Added tests for inpaint region commands to ensure functionality. - Updated various components to support new inpaint features and improve user experience.
572 lines
33 KiB
TypeScript
572 lines
33 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, TextT } 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 type { TextLayer, TextStyle } from "@core/text-layer";
|
|
import { builtInTextFonts } from "@core/text-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, addTextLayer, 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";
|
|
import { BottomControlColorPicker } from "./bottom-controls/ColorPicker";
|
|
import { BottomControlSlider } from "./bottom-controls/Slider";
|
|
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./bottom-controls/SelectMenu";
|
|
|
|
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={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] flex-col overflow-hidden rounded-xl px-3 text-xs text-white 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-14 items-center border-b border-white/[0.06]">
|
|
<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-8 flex-none place-items-center"><Plus size={17} /></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-8 flex-none place-items-center"><Plus size={17} /></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>
|
|
<button type="button" className={toolbarButtonClass()} aria-label="Add text" title="Add text layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addTextLayer(document, selectedArtboardId, selectedLayer, dispatch)}><TextT size={24} /></button>
|
|
</div>
|
|
</header>
|
|
<div className="my-2 grid grid-cols-5 gap-1 border-b border-white/[0.06] pb-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}
|
|
{selectedLayer?.layer.type === "text" ? <TextInspector 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-3 last:mb-0">
|
|
<div
|
|
className={`flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-300 text-slate-950" : "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-6 rounded bg-black/10 px-1.5 py-0.5 text-center text-[0.65rem] text-black/45" : "min-w-6 rounded bg-white/[0.06] px-1.5 py-0.5 text-center text-[0.65rem] text-white/40"}>{displayLayerCount}</span>
|
|
</div>
|
|
<div className="mt-1 space-y-1 pl-3">
|
|
{displayLayerCount === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-white/10 px-3 py-3 text-center text-white/30">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 TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) {
|
|
const [content, setContent] = useState(layer.content);
|
|
const [style, setStyle] = useState<TextStyle>({ ...layer.style });
|
|
const [fontSizeDraft, setFontSizeDraft] = useState(String(layer.style.fontSize));
|
|
const [lineHeightDraft, setLineHeightDraft] = useState(String(layer.style.lineHeight));
|
|
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); setFontSizeDraft(String(layer.style.fontSize)); setLineHeightDraft(String(layer.style.lineHeight)); }, [layer.id, layer.content, layer.style]);
|
|
const updateText = (nextContent: string) => {
|
|
setContent(nextContent);
|
|
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content: nextContent, style });
|
|
};
|
|
const updateStyle = (nextStyle: TextStyle) => {
|
|
setStyle(nextStyle);
|
|
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style: nextStyle });
|
|
};
|
|
const commitNumber = (field: "fontSize" | "lineHeight", draft: string, reset: (value: string) => void) => {
|
|
const next = Number(draft.trim());
|
|
if (draft.trim() === "" || !Number.isFinite(next)) { reset(String(layer.style[field])); return; }
|
|
const nextStyle = { ...style, [field]: next };
|
|
setStyle(nextStyle);
|
|
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style: nextStyle });
|
|
};
|
|
return <section aria-label="Text settings" className="mb-2 space-y-2 border-b border-white/[0.07] px-0.5 pb-3">
|
|
<textarea aria-label="Text content" className="w-full resize-y rounded-md bg-white/[0.035] p-2 text-xs outline-none ring-1 ring-white/[0.07] focus:ring-sky-300/50" rows={2} value={content} disabled={layer.locked} onChange={(event) => updateText(event.target.value)} onKeyDown={(event) => event.stopPropagation()} />
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<InspectorSelect value={style.fontFamily} options={fontOptions} ariaLabel="Font family" onValueChange={(fontFamily) => updateStyle({ ...style, fontFamily })} />
|
|
<input aria-label="Font size" className={textFieldClass()} type="text" inputMode="numeric" value={fontSizeDraft} disabled={layer.locked} onFocus={(event) => event.currentTarget.select()} onChange={(event) => { const draft = event.target.value; setFontSizeDraft(draft); const next = Number(draft); if (draft.trim() && Number.isFinite(next)) updateStyle({ ...style, fontSize: next }); }} onBlur={() => commitNumber("fontSize", fontSizeDraft, setFontSizeDraft)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setFontSizeDraft(String(layer.style.fontSize)); event.currentTarget.blur(); } }} />
|
|
<BottomControlColorPicker value={style.color} aria-label="Text color" onValueChange={(color) => updateStyle({ ...style, color })} />
|
|
<InspectorSelect value={String(style.fontWeight)} options={weightOptions} ariaLabel="Font weight" onValueChange={(fontWeight) => updateStyle({ ...style, fontWeight: Number(fontWeight) as 400 | 700 })} />
|
|
<InspectorSelect value={style.fontStyle} options={styleOptions} ariaLabel="Font style" onValueChange={(fontStyle) => updateStyle({ ...style, fontStyle })} />
|
|
<InspectorSelect value={style.alignment} options={alignmentOptions} ariaLabel="Text alignment" onValueChange={(alignment) => updateStyle({ ...style, alignment })} />
|
|
<input aria-label="Line height" className={textFieldClass()} type="text" inputMode="decimal" value={lineHeightDraft} disabled={layer.locked} onFocus={(event) => event.currentTarget.select()} onChange={(event) => { const draft = event.target.value; setLineHeightDraft(draft); const next = Number(draft); if (draft.trim() && Number.isFinite(next)) updateStyle({ ...style, lineHeight: next }); }} onBlur={() => commitNumber("lineHeight", lineHeightDraft, setLineHeightDraft)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setLineHeightDraft(String(layer.style.lineHeight)); event.currentTarget.blur(); } }} />
|
|
</div>
|
|
<p className="text-[10px] text-white/40">Built-in system fonts only. Missing fonts fall back to Arial/sans-serif; exact glyph shapes can vary by platform.</p>
|
|
</section>;
|
|
}
|
|
|
|
const fontOptions = builtInTextFonts.map((font) => ({ value: font, label: font }));
|
|
const weightOptions = [{ value: "400", label: "Regular" }, { value: "700", label: "Bold" }] as const;
|
|
const styleOptions = [{ value: "normal", label: "Normal" }, { value: "italic", label: "Italic" }] as const;
|
|
const alignmentOptions = [{ value: "left", label: "Left" }, { value: "center", label: "Center" }, { value: "right", label: "Right" }] as const;
|
|
|
|
function InspectorSelect<T extends string>({ value, options, ariaLabel, onValueChange }: { value: T; options: readonly BottomControlSelectOption<T>[]; ariaLabel: string; onValueChange: (value: T) => void }) {
|
|
return <BottomControlSelectMenu value={value} options={options} aria-label={ariaLabel} placement="inline" onValueChange={onValueChange} />;
|
|
}
|
|
|
|
function textFieldClass() { return "h-7 min-w-0 rounded bg-black/20 px-1.5 font-mono text-xs text-white outline-none ring-1 ring-white/[0.07] focus:ring-sky-300/50"; }
|
|
|
|
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;
|
|
|
|
return (
|
|
<section aria-label="Adjustment settings" className="mb-2 border-b border-white/[0.07] px-0.5 pb-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>
|
|
<BottomControlSlider
|
|
className="mt-1 w-full"
|
|
min={-1}
|
|
max={1}
|
|
step={0.01}
|
|
value={value}
|
|
disabled={layer.locked}
|
|
aria-label={`${label} adjustment`}
|
|
onValueChange={(value) => {
|
|
const next = withAdjustmentValue(draft, key, value);
|
|
setDraft(next);
|
|
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: next });
|
|
}}
|
|
/>
|
|
</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 === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
|
const inpaintRegion = document.inpaintRegions.find((region) => region.targetLayerId === layer.id && region.enabled);
|
|
const inpaintMaskAsset = inpaintRegion ? documentIndex.assetById.get(inpaintRegion.maskAssetId) : undefined;
|
|
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
|
|
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
|
const editingInpaintRegion = Boolean(maskEdit?.kind === "inpaintRegion" && inpaintRegion && maskEdit.inpaintRegionId === inpaintRegion.id);
|
|
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-10 w-full items-center gap-2 rounded-lg px-2 text-left transition ${editingMask ? "bg-sky-300 text-slate-950" : selected ? "bg-white text-slate-950" : "text-white/70 hover:bg-white/[0.06] 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 px-2 py-0.5 text-[0.68rem] ${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 bg-black/10 px-2 py-0.5 text-[0.68rem] text-black/65 transition hover:bg-black/15" : "rounded bg-white/[0.04] px-2 py-0.5 text-[0.68rem] 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-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingMask ? "border-sky-300 text-sky-50" : "border-sky-300/25 text-sky-100/65"}`}>
|
|
<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-8 rounded-md px-3 text-xs font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/70 hover:bg-white/[0.07] 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 === "image" || maskLayer.type === "raster") ? (
|
|
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="h-8 rounded-md px-3 text-xs font-medium text-sky-100/50 transition hover:bg-red-400/15 hover:text-red-100"
|
|
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{inpaintRegion ? (
|
|
<div role="group" aria-label={`AI edit region attached to ${layer.name}`} className={`relative mt-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingInpaintRegion ? "border-red-300 text-red-50" : "border-red-300/25 text-red-100/65"}`}>
|
|
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-red-300/40" />
|
|
{inpaintMaskAsset ? <img src={inpaintMaskAsset.source} alt={`${layer.name} AI edit mask preview`} className="h-8 w-11 rounded-md bg-black/30 object-cover ring-1 ring-red-300/30" /> : <Stack size={24} weight="fill" />}
|
|
<span className="min-w-28 flex-1 truncate font-medium">{editingInpaintRegion ? "Editing AI region" : inpaintRegion.name}</span>
|
|
{inpaintMaskAsset ? <MaskStatus asset={inpaintMaskAsset} purpose="inpaint" /> : null}
|
|
<button type="button" className={`h-8 rounded-md px-3 text-xs font-medium ${editingInpaintRegion ? "bg-red-200 text-red-950" : "hover:bg-white/[0.07]"}`} onClick={() => editingInpaintRegion ? dispatch(commandIds.toolExitMaskEdit, undefined) : dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId: inpaintRegion.id })}>{editingInpaintRegion ? "Done" : "Edit"}</button>
|
|
<button type="button" className="h-8 rounded-md px-3 text-xs font-medium text-red-100/50 transition hover:bg-red-400/15 hover:text-red-100" onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: inpaintRegion.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-md bg-white px-2.5 py-1 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-8 items-center justify-center rounded-lg text-white/60 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 [&>svg]:size-[17px]";
|
|
}
|
|
|
|
function labeledToolbarButtonClass() {
|
|
return "inline-flex h-8 flex-1 items-center rounded-lg pr-2 text-xs font-medium text-white/65 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
|
|
}
|
|
|
|
function maskActionButtonClass() {
|
|
return "rounded-md bg-white/[0.05] px-2 py-1 text-[0.68rem] font-semibold text-white/55 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35";
|
|
}
|