feat(canvas): frame-based layer separation UI
Render and manage the separated layers produced by the edit-elements action as a manual-frame container with attached image and text children. - canvasNodeOps: frame container/child helpers, render ordering, auto-attach nodes to containing frames, and layer reordering - CanvasWorkspace wires frame-aware selection, drag, resize, hide/lock, and layer reorder through the new ops - CanvasPanels: expandable layer tree with rename/reorder - designGateway.submitGeneratorTask + GeneratorTaskSubmitResponse and bbox/generator_name artifact metadata in the domain - ArtboardPreview shows a working state for generating frame layers - i18n: expandLayer / renameLayer / layerTitlePlaceholder; styles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,10 @@ export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetS
|
||||
);
|
||||
}
|
||||
|
||||
if (node.status === "generating" && node.type === "frame" && node.layerRole === "manual-frame" && isImageUrl(node.content)) {
|
||||
return <WorkingImagePreview node={node} label={node.title} style={overlayUiStyle} />;
|
||||
}
|
||||
|
||||
if (node.status === "generating" && node.type === "image" && isImageUrl(node.content)) {
|
||||
return <WorkingImagePreview node={node} label={node.title} style={overlayUiStyle} />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Frame, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Download, Eye, EyeOff, Frame, GripVertical, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from "react";
|
||||
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { thumbnailImageUrl } from "@/ui/lib/imageDelivery";
|
||||
import { isFrameContainerNode, type LayerReorderItem, type LayerReorderPlacement, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
|
||||
import { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
|
||||
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
|
||||
|
||||
@@ -18,6 +19,11 @@ type LayerPanelItem =
|
||||
node: CanvasNode;
|
||||
layers: CanvasNode[];
|
||||
}
|
||||
| {
|
||||
type: "frame";
|
||||
node: CanvasNode;
|
||||
layers: CanvasNode[];
|
||||
}
|
||||
| {
|
||||
type: "group";
|
||||
id: string;
|
||||
@@ -125,6 +131,7 @@ export function CanvasLayerPanel({
|
||||
onToggleLocked,
|
||||
onToggleHiddenMany,
|
||||
onToggleLockedMany,
|
||||
onReorderLayer,
|
||||
onClose
|
||||
}: {
|
||||
nodes: CanvasNode[];
|
||||
@@ -138,10 +145,65 @@ export function CanvasLayerPanel({
|
||||
onToggleLocked: (nodeId: string) => void;
|
||||
onToggleHiddenMany: (nodeIds: string[]) => void;
|
||||
onToggleLockedMany: (nodeIds: string[]) => void;
|
||||
onReorderLayer: (request: LayerReorderRequest) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]);
|
||||
const [collapsedLayerIds, setCollapsedLayerIds] = useState<Set<string>>(() => new Set());
|
||||
const [draggingItem, setDraggingItem] = useState<LayerReorderItem | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ item: LayerReorderItem; placement: LayerReorderPlacement } | null>(null);
|
||||
const draggingKey = draggingItem ? layerDragKey(draggingItem) : "";
|
||||
const dropTargetKey = dropTarget ? layerDragKey(dropTarget.item) : "";
|
||||
const toggleCollapsedLayer = (layerId: string) => {
|
||||
setCollapsedLayerIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(layerId)) {
|
||||
next.delete(layerId);
|
||||
} else {
|
||||
next.add(layerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const clearLayerDrag = () => {
|
||||
setDraggingItem(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
const startLayerDrag = (event: ReactDragEvent<HTMLElement>, item: LayerReorderItem) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("input") || target.closest(".layer-control") || target.closest(".layer-disclosure")) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("application/x-canvas-layer", JSON.stringify(item));
|
||||
setDraggingItem(item);
|
||||
};
|
||||
const updateLayerDropTarget = (event: ReactDragEvent<HTMLElement>, item: LayerReorderItem) => {
|
||||
if (!draggingItem || layerDragKey(draggingItem) === layerDragKey(item)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const placement: LayerReorderPlacement = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
|
||||
setDropTarget({ item, placement });
|
||||
};
|
||||
const dropLayer = (event: ReactDragEvent<HTMLElement>, over: LayerReorderItem) => {
|
||||
if (!draggingItem) return;
|
||||
event.preventDefault();
|
||||
const placement = dropTargetKey === layerDragKey(over) ? dropTarget?.placement ?? "before" : "before";
|
||||
onReorderLayer({ active: draggingItem, over, placement });
|
||||
clearLayerDrag();
|
||||
};
|
||||
const layerRowDragState = (item: LayerReorderItem) => ({
|
||||
dragItem: item,
|
||||
dragging: draggingKey === layerDragKey(item),
|
||||
dropPlacement: dropTargetKey === layerDragKey(item) ? dropTarget?.placement ?? null : null,
|
||||
onDragStart: (event: ReactDragEvent<HTMLElement>) => startLayerDrag(event, item),
|
||||
onDragOver: (event: ReactDragEvent<HTMLElement>) => updateLayerDropTarget(event, item),
|
||||
onDrop: (event: ReactDragEvent<HTMLElement>) => dropLayer(event, item),
|
||||
onDragEnd: clearLayerDrag
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="canvas-layer-panel" onPointerDown={(event) => event.stopPropagation()}>
|
||||
@@ -170,10 +232,27 @@ export function CanvasLayerPanel({
|
||||
const selected = selectedIds.has(item.node.id);
|
||||
const hidden = hiddenNodeIds.has(item.node.id);
|
||||
const locked = lockedNodeIds.has(item.node.id);
|
||||
const collapsed = collapsedLayerIds.has(item.node.id);
|
||||
const dragState = layerRowDragState({ kind: "node", id: item.node.id });
|
||||
return (
|
||||
<Fragment key={item.node.id}>
|
||||
<div className={`layer-row layer-group-row boolean-layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(item.node.id)}>
|
||||
<ChevronDown size={14} />
|
||||
<div
|
||||
className={`layer-row layer-group-row boolean-layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""} ${layerRowDragClass(dragState)}`}
|
||||
draggable
|
||||
onClick={() => onSelect(item.node.id)}
|
||||
onDragStart={dragState.onDragStart}
|
||||
onDragOver={dragState.onDragOver}
|
||||
onDrop={dragState.onDrop}
|
||||
onDragEnd={dragState.onDragEnd}
|
||||
>
|
||||
<LayerRowLeading
|
||||
disclosure={{
|
||||
expanded: !collapsed,
|
||||
disabled: item.layers.length === 0,
|
||||
label: collapsed ? t("expandLayer") : t("collapse"),
|
||||
onToggle: () => toggleCollapsedLayer(item.node.id)
|
||||
}}
|
||||
/>
|
||||
<span className="layer-thumb group-thumb boolean-group-thumb">
|
||||
{layerThumb(item.node)}
|
||||
</span>
|
||||
@@ -211,7 +290,7 @@ export function CanvasLayerPanel({
|
||||
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
{item.layers.map((node) => (
|
||||
{!collapsed && item.layers.map((node) => (
|
||||
<LayerNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -224,6 +303,7 @@ export function CanvasLayerPanel({
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
{...layerRowDragState({ kind: "node", id: node.id })}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
@@ -235,9 +315,27 @@ export function CanvasLayerPanel({
|
||||
const selected = nodeIds.some((nodeId) => selectedIds.has(nodeId));
|
||||
const hidden = nodeIds.every((nodeId) => hiddenNodeIds.has(nodeId));
|
||||
const locked = nodeIds.every((nodeId) => lockedNodeIds.has(nodeId));
|
||||
const collapsed = collapsedLayerIds.has(item.id);
|
||||
const dragState = layerRowDragState({ kind: "group", id: item.id });
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
<div className={`layer-row layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelectMany(nodeIds)}>
|
||||
<div
|
||||
className={`layer-row layer-group-row ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""} ${layerRowDragClass(dragState)}`}
|
||||
draggable
|
||||
onClick={() => onSelectMany(nodeIds)}
|
||||
onDragStart={dragState.onDragStart}
|
||||
onDragOver={dragState.onDragOver}
|
||||
onDrop={dragState.onDrop}
|
||||
onDragEnd={dragState.onDragEnd}
|
||||
>
|
||||
<LayerRowLeading
|
||||
disclosure={{
|
||||
expanded: !collapsed,
|
||||
disabled: item.layers.length === 0,
|
||||
label: collapsed ? t("expandLayer") : t("collapse"),
|
||||
onToggle: () => toggleCollapsedLayer(item.id)
|
||||
}}
|
||||
/>
|
||||
<span className="layer-thumb group-thumb">
|
||||
<Layers size={17} />
|
||||
</span>
|
||||
@@ -275,7 +373,7 @@ export function CanvasLayerPanel({
|
||||
{hidden ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
{item.layers.map((node) => (
|
||||
{!collapsed && item.layers.map((node) => (
|
||||
<LayerNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -287,6 +385,47 @@ export function CanvasLayerPanel({
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
{...layerRowDragState({ kind: "node", id: node.id })}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "frame") {
|
||||
const collapsed = collapsedLayerIds.has(item.node.id);
|
||||
return (
|
||||
<Fragment key={item.node.id}>
|
||||
<LayerNodeRow
|
||||
node={item.node}
|
||||
disclosure={{
|
||||
expanded: !collapsed,
|
||||
disabled: item.layers.length === 0,
|
||||
label: collapsed ? t("expandLayer") : t("collapse"),
|
||||
onToggle: () => toggleCollapsedLayer(item.node.id)
|
||||
}}
|
||||
selected={selectedIds.has(item.node.id)}
|
||||
hidden={hiddenNodeIds.has(item.node.id)}
|
||||
locked={lockedNodeIds.has(item.node.id)}
|
||||
onSelect={onSelect}
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
{...layerRowDragState({ kind: "node", id: item.node.id })}
|
||||
/>
|
||||
{!collapsed && item.layers.map((node) => (
|
||||
<LayerNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
nested
|
||||
selected={selectedIds.has(node.id)}
|
||||
hidden={hiddenNodeIds.has(node.id)}
|
||||
locked={lockedNodeIds.has(node.id)}
|
||||
onSelect={onSelect}
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
{...layerRowDragState({ kind: "node", id: node.id })}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
@@ -305,6 +444,7 @@ export function CanvasLayerPanel({
|
||||
onRenameNode={onRenameNode}
|
||||
onToggleHidden={onToggleHidden}
|
||||
onToggleLocked={onToggleLocked}
|
||||
{...layerRowDragState({ kind: "node", id: node.id })}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -314,8 +454,50 @@ export function CanvasLayerPanel({
|
||||
);
|
||||
}
|
||||
|
||||
type LayerDisclosure = {
|
||||
expanded: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
function LayerRowLeading({ disclosure }: { disclosure?: LayerDisclosure }) {
|
||||
if (!disclosure) {
|
||||
return (
|
||||
<span className="layer-drag-handle" aria-hidden="true">
|
||||
<GripVertical size={13} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="layer-disclosure"
|
||||
aria-label={disclosure.label}
|
||||
title={disclosure.label}
|
||||
disabled={disclosure.disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!disclosure.disabled) disclosure.onToggle();
|
||||
}}
|
||||
>
|
||||
{disclosure.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function layerRowDragClass({ dragging, dropPlacement }: { dragging?: boolean; dropPlacement?: LayerReorderPlacement | null }) {
|
||||
return `${dragging ? "is-dragging" : ""} ${dropPlacement === "before" ? "drop-before" : ""} ${dropPlacement === "after" ? "drop-after" : ""}`;
|
||||
}
|
||||
|
||||
function layerDragKey(item: LayerReorderItem) {
|
||||
return `${item.kind}:${item.id}`;
|
||||
}
|
||||
|
||||
function LayerNodeRow({
|
||||
node,
|
||||
disclosure,
|
||||
selected,
|
||||
hidden,
|
||||
locked,
|
||||
@@ -324,9 +506,17 @@ function LayerNodeRow({
|
||||
onRenameNode,
|
||||
onToggleHidden,
|
||||
onToggleLocked,
|
||||
controlsVisible = true
|
||||
controlsVisible = true,
|
||||
dragItem,
|
||||
dragging,
|
||||
dropPlacement,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd
|
||||
}: {
|
||||
node: CanvasNode;
|
||||
disclosure?: LayerDisclosure;
|
||||
selected: boolean;
|
||||
hidden: boolean;
|
||||
locked: boolean;
|
||||
@@ -336,13 +526,20 @@ function LayerNodeRow({
|
||||
onToggleHidden: (nodeId: string) => void;
|
||||
onToggleLocked: (nodeId: string) => void;
|
||||
controlsVisible?: boolean;
|
||||
dragItem?: LayerReorderItem;
|
||||
dragging?: boolean;
|
||||
dropPlacement?: LayerReorderPlacement | null;
|
||||
onDragStart?: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragOver?: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDrop?: (event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEnd?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [draftName, setDraftName] = useState(node.title);
|
||||
const title = layerTitle(node, t);
|
||||
const canRename = node.type === "image" && !locked;
|
||||
const canRename = controlsVisible && !locked;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingName) setDraftName(node.title);
|
||||
@@ -378,15 +575,24 @@ function LayerNodeRow({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`layer-row ${nested ? "nested" : ""} ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""}`} onClick={() => onSelect(node.id)}>
|
||||
<div
|
||||
className={`layer-row ${nested ? "nested" : ""} ${selected ? "selected" : ""} ${hidden ? "is-hidden" : ""} ${layerRowDragClass({ dragging: Boolean(dragging), dropPlacement: dropPlacement ?? null })}`}
|
||||
draggable={Boolean(dragItem) && !editingName}
|
||||
onClick={() => onSelect(node.id)}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<LayerRowLeading disclosure={disclosure} />
|
||||
<span className="layer-thumb">{layerThumb(node)}</span>
|
||||
{editingName ? (
|
||||
<span className="layer-name layer-name-editing" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftName}
|
||||
placeholder={t("imageTitlePlaceholder")}
|
||||
aria-label={t("renameImageNode")}
|
||||
placeholder={t("layerTitlePlaceholder")}
|
||||
aria-label={t("renameLayer")}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onBlur={commitEditingName}
|
||||
onKeyDown={(event) => {
|
||||
@@ -405,7 +611,7 @@ function LayerNodeRow({
|
||||
<button
|
||||
type="button"
|
||||
className="layer-name"
|
||||
aria-label={canRename ? t("renameImageNode") : undefined}
|
||||
aria-label={canRename ? t("renameLayer") : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect(node.id);
|
||||
@@ -452,6 +658,7 @@ function LayerNodeRow({
|
||||
}
|
||||
|
||||
function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const groups = new Map<string, CanvasNode[]>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) return;
|
||||
@@ -462,6 +669,28 @@ function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
|
||||
const items: LayerPanelItem[] = [];
|
||||
[...nodes].reverse().forEach((node) => {
|
||||
if (isBooleanShapeOperandNode(node, nodes)) return;
|
||||
const parent = node.parentId ? nodeById.get(node.parentId) : null;
|
||||
if (parent && isFrameContainerNode(parent)) return;
|
||||
if (node.parentId) {
|
||||
if (emittedGroups.has(node.parentId)) return;
|
||||
const groupNodes = groups.get(node.parentId) ?? [node];
|
||||
emittedGroups.add(node.parentId);
|
||||
items.push({
|
||||
type: "group",
|
||||
id: node.parentId,
|
||||
nodes: groupNodes,
|
||||
layers: [...groupNodes].reverse()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isFrameContainerNode(node)) {
|
||||
items.push({
|
||||
type: "frame",
|
||||
node,
|
||||
layers: [...(groups.get(node.id) ?? [])].reverse()
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isBooleanShapeGroupNode(node)) {
|
||||
items.push({
|
||||
type: "booleanGroup",
|
||||
@@ -470,19 +699,7 @@ function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!node.parentId) {
|
||||
items.push({ type: "node", node });
|
||||
return;
|
||||
}
|
||||
if (emittedGroups.has(node.parentId)) return;
|
||||
const groupNodes = groups.get(node.parentId) ?? [node];
|
||||
emittedGroups.add(node.parentId);
|
||||
items.push({
|
||||
type: "group",
|
||||
id: node.parentId,
|
||||
nodes: groupNodes,
|
||||
layers: [...groupNodes].reverse()
|
||||
});
|
||||
items.push({ type: "node", node });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
@@ -533,7 +750,7 @@ function isImageUrl(value: string) {
|
||||
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim());
|
||||
}
|
||||
|
||||
const nonModelGeneratedLayerRoles = new Set(["pen-stroke", "background-removed", "clean-image", "mockup-print", "merge-layer", "manual-frame", "image-generator"]);
|
||||
const nonModelGeneratedLayerRoles = new Set(["pen-stroke", "background-removed", "clean-image", "foreground-image", "mockup-print", "merge-layer", "manual-frame", "image-generator"]);
|
||||
|
||||
function generatedFileURLSet(messages: AgentMessage[]) {
|
||||
const urls = new Set<string>();
|
||||
|
||||
Reference in New Issue
Block a user