feat(layers): add layer management panel

This commit is contained in:
syntaxbullet
2026-07-03 17:26:50 +02:00
parent ba3f253ee5
commit fedeaffa57
27 changed files with 1525 additions and 156 deletions

View File

@@ -1,12 +1,12 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { ImageStudioApp } from "@app/app";
import { BottomControlsIsland } from "./BottomControlsIsland";
import { CanvasViewport } from "./CanvasViewport";
import { LayersSheet } from "./LayersSheet";
import { ToolOverlay } from "./ToolOverlay";
import { labelForTool } from "./toolLabels";
import { resolveTransformTargetBounds } from "@editor/transform-targets";
import { getSelectionSummary } from "./selectionSummary";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import { handleDeleteSelectionKey, keybindEventFromKeyboardEvent } from "@input/index";
import { useAppState } from "./useAppState";
import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland";
@@ -22,10 +22,35 @@ export function App({ app }: AppProps) {
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false);
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
const transformBounds = state.editor.transformSession
? resolveTransformTargetBounds(state.document, state.editor.transformSession.target)
: undefined;
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target;
const editableTarget =
target instanceof HTMLElement &&
(target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement);
if (event.altKey || event.ctrlKey || event.metaKey || editableTarget) return;
if (event.key.toLowerCase() === "l") {
setLayersOpen(true);
event.preventDefault();
return;
}
const consumed = handleDeleteSelectionKey({
event: keybindEventFromKeyboardEvent(event),
selection: app.store.getState().editor.selection,
dispatch: app.store.dispatch,
});
if (consumed) event.preventDefault();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [app.store]);
const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined;
return (
<main className="relative h-full overflow-hidden bg-background text-foreground">
@@ -44,16 +69,15 @@ export function App({ app }: AppProps) {
dispatch={app.store.dispatch}
/>
</div>
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} onClose={() => setLayersOpen(false)} />
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} dispatch={app.store.dispatch} onClose={() => setLayersOpen(false)} />
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<BottomControlsIsland
viewport={state.editor.viewport}
visible={Boolean(transformBounds) || selectionSummary.type !== "none" || viewportActivityIsland.visible}
visible={Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
selection={selectionSummary}
transformBounds={transformBounds}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
dispatch={app.store.dispatch}
onOpenLayers={() => setLayersOpen(true)}
/>
</div>
<CanvasViewport store={app.store} />

View File

@@ -1,25 +1,22 @@
import type { AppStore } from "@editor/store";
import type { ViewportState } from "@editor/state";
import { PanControls } from "./bottom-controls/PanControls";
import { SelectionControls } from "./bottom-controls/SelectionControls";
import { TransformControls } from "./bottom-controls/TransformControls";
import { ZoomControls } from "./bottom-controls/ZoomControls";
import type { Rect } from "@core/geometry";
import type { SelectionSummary } from "./selectionSummary";
import type { TransformTarget } from "@editor/transform";
export type BottomControlsAction = "pan" | "zoom";
export type BottomControlsIslandProps = {
viewport: ViewportState;
visible: boolean;
action: BottomControlsAction;
selection: SelectionSummary;
transformBounds?: Rect;
transformTarget?: TransformTarget;
dispatch: AppStore["dispatch"];
onOpenLayers: () => void;
};
export function BottomControlsIsland({ viewport, visible, action, selection, transformBounds, dispatch, onOpenLayers }: BottomControlsIslandProps) {
export function BottomControlsIsland({ viewport, visible, action, transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) {
const zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y);
@@ -31,10 +28,8 @@ export function BottomControlsIsland({ viewport, visible, action, selection, tra
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>
{transformBounds ? (
<TransformControls bounds={transformBounds} />
) : selection.type !== "none" ? (
<SelectionControls selection={selection} onOpenLayers={onOpenLayers} />
{transformBounds && transformTarget ? (
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
) : action === "pan" ? (
<PanControls x={x} y={y} />
) : (

View File

@@ -1,40 +1,145 @@
import { Eye, EyeSlash, Lock, LockOpen, X } from "@phosphor-icons/react";
import { useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash, X } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ArtboardId } from "@core/id";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index";
import { downloadArtboardPng } from "./exportArtboardPng";
export type LayersSheetProps = {
document: ImageDocument;
selection: SelectionState;
open: boolean;
dispatch: AppStore["dispatch"];
onClose: () => void;
};
export function LayersSheet({ document, selection, open, onClose }: LayersSheetProps) {
export function LayersSheet({ document, selection, open, dispatch, onClose }: LayersSheetProps) {
const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id;
const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]);
const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0);
const canUngroup = selectedLayer?.layer.type === "group";
const draggedLayerId = useRef<string>();
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
return (
<aside
aria-hidden={!open}
className={`pointer-events-auto absolute right-3 top-12 z-20 w-72 rounded-xl border border-white/10 bg-black/75 text-xs text-white shadow-xl backdrop-blur transition-all duration-200 ${
className={`pointer-events-auto absolute right-4 top-12 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white shadow-2xl shadow-black/40 backdrop-blur-xl transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
}`}
>
<header className="flex h-10 items-center justify-between border-b border-white/10 px-3">
<span className="font-medium">Layers</span>
<header className="flex h-14 items-center justify-between border-b border-white/10 bg-white/[0.03] px-4">
<div>
<div className="font-medium tracking-wide">Layers</div>
<div className="text-xs text-white/40">Press L to open</div>
</div>
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
<X size={16} weight="regular" />
<X size={18} weight="regular" />
</button>
</header>
<div className="max-h-96 overflow-auto p-2">
<div className="flex flex-wrap gap-2 border-b border-white/10 bg-black/20 p-3">
<button type="button" className={toolbarButtonClass()} onClick={() => addArtboard(document, dispatch)}>
<Plus size={16} /> Artboard
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<Plus size={16} /> Layer
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
<FolderPlus size={16} /> Group
</button>
<button type="button" className={toolbarButtonClass()} disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={16} /> Group selected
</button>
<button type="button" className={toolbarButtonClass()} disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
Ungroup
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
Up
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
Down
</button>
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
<Trash size={16} /> Delete
</button>
</div>
<div className="max-h-[32rem] overflow-auto p-3">
{document.artboards.map((artboard) => (
<section key={artboard.id} className="mb-2 last:mb-0">
<div className={`rounded-md px-2 py-1 text-white/60 ${selection.artboardId === artboard.id ? "bg-white/10 text-white" : ""}`}>
{artboard.name}
</div>
<div className="mt-1 space-y-1 pl-2">
{artboard.layers.length === 0 ? (
<div className="px-2 py-1 text-white/40">No layers</div>
<section key={artboard.id} className="mb-4 last:mb-0">
<div
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-400/15 text-sky-100 ring-1 ring-sky-300/20" : "text-white/65 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="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={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
</button>
<button type="button" className="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={17} weight="regular" /> : <LockOpen size={17} 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);
}}
/>
) : (
artboard.layers.map((layer) => <LayerRow key={layer.id} layer={layer} depth={0} selectedLayerIds={selection.layerIds} />)
<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="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={17} weight="regular" />
</button>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{artboard.layers.length}</span>
</div>
<div className="mt-2 space-y-1.5 pl-3">
{artboard.layers.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-white/35">No layers yet</div>
) : (
artboard.layers.map((layer) => (
<LayerRow
key={layer.id}
document={document}
artboardId={artboard.id}
layer={layer}
depth={0}
selectedLayerIds={selection.layerIds}
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
selectedMaskLayer={selectedLayer}
dispatch={dispatch}
/>
))
)}
</div>
</section>
@@ -44,20 +149,256 @@ export function LayersSheet({ document, selection, open, onClose }: LayersSheetP
);
}
function LayerRow({ layer, depth, selectedLayerIds }: { layer: Layer; depth: number; selectedLayerIds: string[] }) {
function LayerRow({
document,
artboardId,
layer,
depth,
selectedLayerIds,
draggedLayerId,
editingTitle,
setEditingTitle,
selectedMaskLayer,
dispatch,
}: {
document: ImageDocument;
artboardId: ArtboardId;
layer: Layer;
depth: number;
selectedLayerIds: string[];
draggedLayerId: MutableRefObject<string | undefined>;
editingTitle: EditingTitle | undefined;
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
selectedMaskLayer?: LayerInfo;
dispatch: AppStore["dispatch"];
}) {
const selected = selectedLayerIds.includes(layer.id);
const layerInfo = findLayerInfoInDocument(document, layer.id);
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
const maskIndent = maskLayer ? 24 : 0;
const canSetMask =
Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId);
return (
<div>
<div className={`flex items-center gap-2 rounded-md px-2 py-1 ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`} style={{ paddingLeft: 8 + depth * 12 }}>
{layer.visible ? <Eye size={14} weight="regular" /> : <EyeSlash size={14} weight="regular" />}
{layer.locked ? <Lock size={14} weight="regular" /> : <LockOpen size={14} weight="regular" />}
<span className="min-w-0 flex-1 truncate">{layer.name}</span>
<div
draggable
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
style={{ paddingLeft: 12 + depth * 16 + maskIndent }}
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;
}}
>
{maskLayer ? <span className={selected ? "text-black/40" : "text-sky-200/55"}></span> : null}
<button type="button" className={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={17} weight="regular" /> : <EyeSlash size={17} 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>
)}
<button
type="button"
className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"}
aria-label={layer.clippingMask ? "Clear layer mask" : "Use selected layer as mask"}
title={layer.clippingMask ? "Clear mask" : "Use selected layer as mask"}
disabled={!layer.clippingMask && !canSetMask}
onClick={() => dispatch(commandIds.documentSetLayerClippingMask, { layerId: layer.id, maskLayerId: layer.clippingMask ? undefined : selectedMaskLayer?.layer.id })}
>
<Stack size={17} weight={layer.clippingMask ? "fill" : "regular"} />
</button>
<button type="button" className={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={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
</button>
</div>
{layer.type === "group" ? layer.children.map((child) => <LayerRow key={child.id} layer={child} depth={depth + 1} selectedLayerIds={selectedLayerIds} />) : null}
{maskLayer ? (
<div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}>
<span className="h-px w-5 bg-sky-200/25" />
<span>masked by {maskLayer.name}</span>
</div>
) : null}
{layer.type === "group"
? layer.children.map((child) => (
<LayerRow
key={child.id}
document={document}
artboardId={artboardId}
layer={child}
depth={depth + 1}
selectedLayerIds={selectedLayerIds}
draggedLayerId={draggedLayerId}
editingTitle={editingTitle}
setEditingTitle={setEditingTitle}
selectedMaskLayer={selectedMaskLayer}
dispatch={dispatch}
/>
))
: null}
</div>
);
}
function iconButtonClass() {
return "grid size-7 place-items-center rounded-full text-white/80 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none";
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-md bg-white px-2 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 deleteSelection(selection: SelectionState, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
if (selectedLayer) {
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
return;
}
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
}
function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
const index = document.artboards.length + 1;
dispatch(commandIds.documentAddArtboard, {
id: crypto.randomUUID(),
name: `Artboard ${index}`,
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
});
}
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
if (!artboard) return;
const assetId = crypto.randomUUID();
const layerId = crypto.randomUUID();
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const source = `data:image/svg+xml,${encodeURIComponent(`<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.documentAddImageLayer, {
artboardId,
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
layer: {
id: layerId,
type: "image",
name: "Layer",
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
},
});
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
}
function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
}
function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
}
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
if (!siblings) return;
const currentIndex = siblings.findIndex((layer) => layer.id === info.layer.id);
if (currentIndex < 0) return;
dispatch(commandIds.documentMoveLayer, {
layerId: info.layer.id,
toArtboardId: info.artboardId,
toParentGroupId: info.parentGroupId,
toIndex: currentIndex + direction,
});
}
function createGroup(name: string): Layer {
return {
id: crypto.randomUUID(),
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [],
};
}
function toolbarButtonClass() {
return "inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 text-white/75 transition hover:border-white/20 hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
}
function iconButtonClass() {
return "grid size-8 place-items-center rounded-full text-white/70 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none";
}

View File

@@ -1,36 +0,0 @@
import { Stack } from "@phosphor-icons/react";
import type { SelectionSummary } from "../selectionSummary";
import { BottomControlDivider } from "./Divider";
import { bottomControlButtonClass } from "./styles";
export type SelectionControlsProps = {
selection: SelectionSummary;
onOpenLayers: () => void;
};
export function SelectionControls({ selection, onOpenLayers }: SelectionControlsProps) {
const label = selectionLabel(selection);
return (
<>
<button type="button" className={bottomControlButtonClass()} aria-label="Open layers" title="Open layers" onClick={onOpenLayers}>
<Stack size={16} weight="regular" />
</button>
<BottomControlDivider />
<span className="max-w-48 truncate px-1 text-white">{label}</span>
</>
);
}
function selectionLabel(selection: SelectionSummary) {
switch (selection.type) {
case "artboard":
return `${selection.name} · ${selection.layerCount} layers`;
case "layer":
return selection.name;
case "multi-layer":
return `${selection.count} layers selected`;
case "none":
return "";
}
}

View File

@@ -1,28 +1,100 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { BoundingBox } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { Rect } from "@core/geometry";
import type { AppStore } from "@editor/store";
import type { TransformTarget } from "@editor/transform";
import { BottomControlDivider } from "./Divider";
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles";
import { bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass } from "./styles";
export type TransformControlsProps = {
bounds: Rect;
target: TransformTarget;
dispatch: AppStore["dispatch"];
};
export function TransformControls({ bounds }: TransformControlsProps) {
type BoundsField = keyof Rect;
export function TransformControls({ bounds, target, dispatch }: TransformControlsProps) {
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
useEffect(() => {
setDraft(draftFromBounds(bounds));
}, [bounds.x, bounds.y, bounds.w, bounds.h]);
const commitField = (field: BoundsField) => {
const value = Number.parseFloat(draft[field]);
if (!Number.isFinite(value)) {
setDraft(draftFromBounds(bounds));
return;
}
dispatch(commandIds.transformSetBounds, {
target,
bounds: {
...bounds,
[field]: field === "w" || field === "h" ? Math.max(1, value) : value,
},
});
};
return (
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
<span className={bottomControlIconSlotClass()}>
<BoundingBox size={16} weight="regular" />
</span>
<BottomControlDivider />
<span className={bottomControlLabelClass()}>X</span>
<span className={bottomControlValueClass()}>{Math.round(bounds.x)}</span>
<span className={bottomControlLabelClass()}>Y</span>
<span className={bottomControlValueClass()}>{Math.round(bounds.y)}</span>
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
<BoundsInput label="Y" field="y" draft={draft.y} setDraft={setDraft} commitField={commitField} />
<BottomControlDivider />
<span className={bottomControlLabelClass()}>W</span>
<span className={bottomControlValueClass()}>{Math.round(bounds.w)}</span>
<span className={bottomControlLabelClass()}>H</span>
<span className={bottomControlValueClass()}>{Math.round(bounds.h)}</span>
<BoundsInput label="W" field="w" draft={draft.w} setDraft={setDraft} commitField={commitField} />
<BoundsInput label="H" field="h" draft={draft.h} setDraft={setDraft} commitField={commitField} />
</div>
);
}
function BoundsInput({
label,
field,
draft,
setDraft,
commitField,
}: {
label: string;
field: BoundsField;
draft: string;
setDraft: Dispatch<SetStateAction<Record<BoundsField, string>>>;
commitField: (field: BoundsField) => void;
}) {
return (
<label className="flex items-center gap-1">
<span className={bottomControlLabelClass()}>{label}</span>
<input
className={bottomControlInputClass()}
inputMode="decimal"
value={draft}
aria-label={label}
onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))}
onBlur={() => commitField(field)}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
commitField(field);
event.currentTarget.blur();
}
if (event.key === "Escape") event.currentTarget.blur();
}}
/>
</label>
);
}
function draftFromBounds(bounds: Rect): Record<BoundsField, string> {
return {
x: String(Math.round(bounds.x)),
y: String(Math.round(bounds.y)),
w: String(Math.round(bounds.w)),
h: String(Math.round(bounds.h)),
};
}

View File

@@ -10,6 +10,10 @@ export function bottomControlValueClass() {
return "min-w-10 text-center text-white";
}
export function bottomControlInputClass() {
return "h-7 w-14 px-1 text-center text-white outline-none transition placeholder:text-white/30 focus:text-white";
}
export function bottomControlIconSlotClass() {
return "grid size-7 place-items-center rounded-full text-white/80";
}

72
view/exportArtboardPng.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset";
import type { Layer } from "@core/layer";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is not available");
if (artboard.backgroundColor !== "transparent") {
context.fillStyle = artboard.backgroundColor;
context.fillRect(0, 0, width, height);
}
context.save();
context.translate(-artboard.bounds.x, -artboard.bounds.y);
for (const layer of artboard.layers) await drawLayer(context, layer, assets);
context.restore();
const url = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = url;
link.download = `${safeFilename(artboard.name)}.png`;
link.click();
}
async function drawLayer(context: CanvasRenderingContext2D, layer: Layer, assets: readonly Asset[]) {
if (!layer.visible) return;
context.save();
context.globalAlpha *= layer.opacity;
if (layer.type === "group") {
for (const child of layer.children) await drawLayer(context, child, assets);
context.restore();
return;
}
const asset = assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) {
context.restore();
return;
}
const image = await loadImage(asset.source);
context.drawImage(
image,
layer.transform.position.x,
layer.transform.position.y,
asset.intrinsicSize.w * layer.transform.scale.x,
asset.intrinsicSize.h * layer.transform.scale.y,
);
context.restore();
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image for export"));
image.src = source;
});
}
function safeFilename(name: string) {
return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard";
}

View File

@@ -1,43 +0,0 @@
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { SelectionState } from "@editor/state";
export type SelectionSummary =
| { type: "none" }
| { type: "artboard"; name: string; layerCount: number }
| { type: "layer"; name: string; layer: Layer }
| { type: "multi-layer"; count: number; layers: Layer[] };
export function getSelectionSummary(document: ImageDocument, selection: SelectionState): SelectionSummary {
const selectedLayers = selection.layerIds.flatMap((layerId) => {
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
return layer ? [layer] : [];
});
if (selectedLayers.length === 1 && selectedLayers[0]) {
return { type: "layer", name: selectedLayers[0].name, layer: selectedLayers[0] };
}
if (selectedLayers.length > 1) {
return { type: "multi-layer", count: selectedLayers.length, layers: selectedLayers };
}
if (selection.artboardId) {
const artboard = document.artboards.find((candidate) => candidate.id === selection.artboardId);
if (artboard) return { type: "artboard", name: artboard.name, layerCount: artboard.layers.length };
}
return { type: "none" };
}
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}