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:
2026-07-09 12:27:00 +08:00
parent 039d166972
commit a1503a1687
9 changed files with 613 additions and 94 deletions
@@ -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} />;
}
+244 -27
View File
@@ -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>();