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
+10
View File
@@ -148,7 +148,9 @@ export type GeneratorTaskArtifact = {
content: string; content: string;
metadata: { metadata: {
artifact_id?: string; artifact_id?: string;
bbox?: number[];
format?: string; format?: string;
generator_name?: string;
height?: number; height?: number;
label?: string; label?: string;
node_id?: string; node_id?: string;
@@ -177,6 +179,14 @@ export type GeneratorTaskResponse = {
}; };
}; };
export type GeneratorTaskSubmitResponse = {
code: number;
message: string;
data: {
generator_task_id: string;
};
};
export type DeleteProjectResponse = { export type DeleteProjectResponse = {
id: string; id: string;
deleted: boolean; deleted: boolean;
+3
View File
@@ -66,6 +66,7 @@
"recentProjects": "Recent Projects", "recentProjects": "Recent Projects",
"viewAll": "View all", "viewAll": "View all",
"collapse": "Collapse", "collapse": "Collapse",
"expandLayer": "Expand layer",
"newProject": "New Project", "newProject": "New Project",
"continueEditing": "Continue", "continueEditing": "Continue",
"inspiration": "Inspiration", "inspiration": "Inspiration",
@@ -126,6 +127,8 @@
"renameCanvasTitle": "Rename canvas", "renameCanvasTitle": "Rename canvas",
"renameImageNode": "Rename image", "renameImageNode": "Rename image",
"imageTitlePlaceholder": "Image name", "imageTitlePlaceholder": "Image name",
"renameLayer": "Rename layer",
"layerTitlePlaceholder": "Layer name",
"projectTitlePlaceholder": "Name this canvas", "projectTitlePlaceholder": "Name this canvas",
"select": "Select", "select": "Select",
"pan": "Move", "pan": "Move",
+3
View File
@@ -66,6 +66,7 @@
"recentProjects": "最近项目", "recentProjects": "最近项目",
"viewAll": "查看全部", "viewAll": "查看全部",
"collapse": "收起", "collapse": "收起",
"expandLayer": "展开图层",
"newProject": "新建项目", "newProject": "新建项目",
"continueEditing": "继续编辑", "continueEditing": "继续编辑",
"inspiration": "灵感发现", "inspiration": "灵感发现",
@@ -126,6 +127,8 @@
"renameCanvasTitle": "重命名画布", "renameCanvasTitle": "重命名画布",
"renameImageNode": "重命名图片", "renameImageNode": "重命名图片",
"imageTitlePlaceholder": "输入图片名称", "imageTitlePlaceholder": "输入图片名称",
"renameLayer": "重命名图层",
"layerTitlePlaceholder": "输入图层名称",
"projectTitlePlaceholder": "输入画布名称", "projectTitlePlaceholder": "输入画布名称",
"select": "选择", "select": "选择",
"pan": "移动", "pan": "移动",
@@ -11,6 +11,7 @@ import type {
DeleteProjectsResponse, DeleteProjectsResponse,
ExtractNodeTextResponse, ExtractNodeTextResponse,
GeneratorTaskResponse, GeneratorTaskResponse,
GeneratorTaskSubmitResponse,
GenerateResult, GenerateResult,
MergeLayersRequest, MergeLayersRequest,
NodeActionRequest, NodeActionRequest,
@@ -358,6 +359,18 @@ export const designGateway = {
return request<GeneratorTaskResponse>(`/api/generator/tasks?${params.toString()}`); return request<GeneratorTaskResponse>(`/api/generator/tasks?${params.toString()}`);
}, },
submitGeneratorTask(projectId: string, generatorName: string, inputArgs: Record<string, unknown>) {
return request<GeneratorTaskSubmitResponse>("/api/generator/tasks", {
method: "POST",
body: JSON.stringify({
cid: createClientId(),
project_id: projectId,
generator_name: generatorName,
input_args: inputArgs
})
});
},
recognizeObject(imageUrl: string, boundingBox: NormalizedBox, lang = "zh") { recognizeObject(imageUrl: string, boundingBox: NormalizedBox, lang = "zh") {
return request<RecognizeObjectResponse>("/api/context/vision/recognize_object", { return request<RecognizeObjectResponse>("/api/context/vision/recognize_object", {
method: "POST", method: "POST",
@@ -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)) { if (node.status === "generating" && node.type === "image" && isImageUrl(node.content)) {
return <WorkingImagePreview node={node} label={node.title} style={overlayUiStyle} />; return <WorkingImagePreview node={node} label={node.title} style={overlayUiStyle} />;
} }
+244 -27
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { ChevronDown, ChevronUp, Download, Eye, EyeOff, Frame, Image as ImageIcon, Layers, Lock, Type, Unlock, X } from "lucide-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 } from "react"; import { Fragment, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent } from "react";
import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design"; import type { AgentMessage, CanvasNode, CanvasViewport } from "@/domain/design";
import { useI18n } from "@/i18n/i18n"; import { useI18n } from "@/i18n/i18n";
import { thumbnailImageUrl } from "@/ui/lib/imageDelivery"; 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 { ShapeNodePreview } from "@/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview";
import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; import { isBooleanShapeGroupNode, isBooleanShapeOperandNode, isShapeNode } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
@@ -18,6 +19,11 @@ type LayerPanelItem =
node: CanvasNode; node: CanvasNode;
layers: CanvasNode[]; layers: CanvasNode[];
} }
| {
type: "frame";
node: CanvasNode;
layers: CanvasNode[];
}
| { | {
type: "group"; type: "group";
id: string; id: string;
@@ -125,6 +131,7 @@ export function CanvasLayerPanel({
onToggleLocked, onToggleLocked,
onToggleHiddenMany, onToggleHiddenMany,
onToggleLockedMany, onToggleLockedMany,
onReorderLayer,
onClose onClose
}: { }: {
nodes: CanvasNode[]; nodes: CanvasNode[];
@@ -138,10 +145,65 @@ export function CanvasLayerPanel({
onToggleLocked: (nodeId: string) => void; onToggleLocked: (nodeId: string) => void;
onToggleHiddenMany: (nodeIds: string[]) => void; onToggleHiddenMany: (nodeIds: string[]) => void;
onToggleLockedMany: (nodeIds: string[]) => void; onToggleLockedMany: (nodeIds: string[]) => void;
onReorderLayer: (request: LayerReorderRequest) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const layers = useMemo(() => buildLayerPanelItems(nodes), [nodes]); 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 ( return (
<aside className="canvas-layer-panel" onPointerDown={(event) => event.stopPropagation()}> <aside className="canvas-layer-panel" onPointerDown={(event) => event.stopPropagation()}>
@@ -170,10 +232,27 @@ export function CanvasLayerPanel({
const selected = selectedIds.has(item.node.id); const selected = selectedIds.has(item.node.id);
const hidden = hiddenNodeIds.has(item.node.id); const hidden = hiddenNodeIds.has(item.node.id);
const locked = lockedNodeIds.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 ( return (
<Fragment key={item.node.id}> <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)}> <div
<ChevronDown size={14} /> 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"> <span className="layer-thumb group-thumb boolean-group-thumb">
{layerThumb(item.node)} {layerThumb(item.node)}
</span> </span>
@@ -211,7 +290,7 @@ export function CanvasLayerPanel({
{hidden ? <EyeOff size={15} /> : <Eye size={15} />} {hidden ? <EyeOff size={15} /> : <Eye size={15} />}
</button> </button>
</div> </div>
{item.layers.map((node) => ( {!collapsed && item.layers.map((node) => (
<LayerNodeRow <LayerNodeRow
key={node.id} key={node.id}
node={node} node={node}
@@ -224,6 +303,7 @@ export function CanvasLayerPanel({
onRenameNode={onRenameNode} onRenameNode={onRenameNode}
onToggleHidden={onToggleHidden} onToggleHidden={onToggleHidden}
onToggleLocked={onToggleLocked} onToggleLocked={onToggleLocked}
{...layerRowDragState({ kind: "node", id: node.id })}
/> />
))} ))}
</Fragment> </Fragment>
@@ -235,9 +315,27 @@ export function CanvasLayerPanel({
const selected = nodeIds.some((nodeId) => selectedIds.has(nodeId)); const selected = nodeIds.some((nodeId) => selectedIds.has(nodeId));
const hidden = nodeIds.every((nodeId) => hiddenNodeIds.has(nodeId)); const hidden = nodeIds.every((nodeId) => hiddenNodeIds.has(nodeId));
const locked = nodeIds.every((nodeId) => lockedNodeIds.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 ( return (
<Fragment key={item.id}> <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"> <span className="layer-thumb group-thumb">
<Layers size={17} /> <Layers size={17} />
</span> </span>
@@ -275,7 +373,7 @@ export function CanvasLayerPanel({
{hidden ? <EyeOff size={15} /> : <Eye size={15} />} {hidden ? <EyeOff size={15} /> : <Eye size={15} />}
</button> </button>
</div> </div>
{item.layers.map((node) => ( {!collapsed && item.layers.map((node) => (
<LayerNodeRow <LayerNodeRow
key={node.id} key={node.id}
node={node} node={node}
@@ -287,6 +385,47 @@ export function CanvasLayerPanel({
onRenameNode={onRenameNode} onRenameNode={onRenameNode}
onToggleHidden={onToggleHidden} onToggleHidden={onToggleHidden}
onToggleLocked={onToggleLocked} 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> </Fragment>
@@ -305,6 +444,7 @@ export function CanvasLayerPanel({
onRenameNode={onRenameNode} onRenameNode={onRenameNode}
onToggleHidden={onToggleHidden} onToggleHidden={onToggleHidden}
onToggleLocked={onToggleLocked} 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({ function LayerNodeRow({
node, node,
disclosure,
selected, selected,
hidden, hidden,
locked, locked,
@@ -324,9 +506,17 @@ function LayerNodeRow({
onRenameNode, onRenameNode,
onToggleHidden, onToggleHidden,
onToggleLocked, onToggleLocked,
controlsVisible = true controlsVisible = true,
dragItem,
dragging,
dropPlacement,
onDragStart,
onDragOver,
onDrop,
onDragEnd
}: { }: {
node: CanvasNode; node: CanvasNode;
disclosure?: LayerDisclosure;
selected: boolean; selected: boolean;
hidden: boolean; hidden: boolean;
locked: boolean; locked: boolean;
@@ -336,13 +526,20 @@ function LayerNodeRow({
onToggleHidden: (nodeId: string) => void; onToggleHidden: (nodeId: string) => void;
onToggleLocked: (nodeId: string) => void; onToggleLocked: (nodeId: string) => void;
controlsVisible?: boolean; 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 { t } = useI18n();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [draftName, setDraftName] = useState(node.title); const [draftName, setDraftName] = useState(node.title);
const title = layerTitle(node, t); const title = layerTitle(node, t);
const canRename = node.type === "image" && !locked; const canRename = controlsVisible && !locked;
useEffect(() => { useEffect(() => {
if (!editingName) setDraftName(node.title); if (!editingName) setDraftName(node.title);
@@ -378,15 +575,24 @@ function LayerNodeRow({
}; };
return ( 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> <span className="layer-thumb">{layerThumb(node)}</span>
{editingName ? ( {editingName ? (
<span className="layer-name layer-name-editing" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}> <span className="layer-name layer-name-editing" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<input <input
ref={inputRef} ref={inputRef}
value={draftName} value={draftName}
placeholder={t("imageTitlePlaceholder")} placeholder={t("layerTitlePlaceholder")}
aria-label={t("renameImageNode")} aria-label={t("renameLayer")}
onChange={(event) => setDraftName(event.target.value)} onChange={(event) => setDraftName(event.target.value)}
onBlur={commitEditingName} onBlur={commitEditingName}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -405,7 +611,7 @@ function LayerNodeRow({
<button <button
type="button" type="button"
className="layer-name" className="layer-name"
aria-label={canRename ? t("renameImageNode") : undefined} aria-label={canRename ? t("renameLayer") : undefined}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onSelect(node.id); onSelect(node.id);
@@ -452,6 +658,7 @@ function LayerNodeRow({
} }
function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] { function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const groups = new Map<string, CanvasNode[]>(); const groups = new Map<string, CanvasNode[]>();
nodes.forEach((node) => { nodes.forEach((node) => {
if (!node.parentId) return; if (!node.parentId) return;
@@ -462,6 +669,28 @@ function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
const items: LayerPanelItem[] = []; const items: LayerPanelItem[] = [];
[...nodes].reverse().forEach((node) => { [...nodes].reverse().forEach((node) => {
if (isBooleanShapeOperandNode(node, nodes)) return; 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)) { if (isBooleanShapeGroupNode(node)) {
items.push({ items.push({
type: "booleanGroup", type: "booleanGroup",
@@ -470,19 +699,7 @@ function buildLayerPanelItems(nodes: CanvasNode[]): LayerPanelItem[] {
}); });
return; return;
} }
if (!node.parentId) { items.push({ type: "node", node });
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()
});
}); });
return items; return items;
} }
@@ -533,7 +750,7 @@ function isImageUrl(value: string) {
return /^(https?:\/\/|data:image\/|blob:|\/)/.test(value.trim()); 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[]) { function generatedFileURLSet(messages: AgentMessage[]) {
const urls = new Set<string>(); const urls = new Set<string>();
@@ -5,6 +5,13 @@ import { nodeBounds, normalizeRotation, type NodeAlignment, type NodeDistributio
export type NodeReorderAction = "forward" | "backward" | "front" | "back"; export type NodeReorderAction = "forward" | "backward" | "front" | "back";
export type NodePosition = { id: string; x: number; y: number }; export type NodePosition = { id: string; x: number; y: number };
export type LayerReorderItem = { kind: "node" | "group"; id: string };
export type LayerReorderPlacement = "before" | "after";
export type LayerReorderRequest = {
active: LayerReorderItem;
over: LayerReorderItem;
placement: LayerReorderPlacement;
};
export function visibleCanvasNodes(nodes: CanvasNode[], hiddenNodeIds: Set<string>) { export function visibleCanvasNodes(nodes: CanvasNode[], hiddenNodeIds: Set<string>) {
return nodes.filter((node) => !hiddenNodeIds.has(node.id) && !isBooleanShapeOperandNode(node, nodes)); return nodes.filter((node) => !hiddenNodeIds.has(node.id) && !isBooleanShapeOperandNode(node, nodes));
@@ -22,6 +29,129 @@ export function moveNodesByDelta(nodes: CanvasNode[], startPositions: NodePositi
}); });
} }
export function isFrameContainerNode(node: CanvasNode) {
return node.type === "frame" && node.layerRole === "manual-frame";
}
export function isFrameChildNode(node: CanvasNode, nodes: CanvasNode[]) {
if (!node.parentId) return false;
const parent = nodes.find((item) => item.id === node.parentId);
return Boolean(parent && isFrameContainerNode(parent));
}
export function nodeIdsWithFrameChildren(nodes: CanvasNode[], nodeIds: Set<string>) {
const next = new Set(nodeIds);
let changed = true;
while (changed) {
changed = false;
nodes.forEach((node) => {
if (!node.parentId || next.has(node.id) || !next.has(node.parentId)) return;
const parent = nodes.find((item) => item.id === node.parentId);
if (!parent || !isFrameContainerNode(parent)) return;
next.add(node.id);
changed = true;
});
}
return next;
}
export function orderedCanvasNodesForRender(nodes: CanvasNode[]) {
const emitted = new Set<string>();
const childrenByParentId = childNodesByParentId(nodes);
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const ordered: CanvasNode[] = [];
const emitNode = (node: CanvasNode) => {
if (emitted.has(node.id)) return;
emitted.add(node.id);
ordered.push(node);
if (!isFrameContainerNode(node)) return;
(childrenByParentId.get(node.id) ?? []).forEach(emitNode);
};
nodes.forEach((node) => {
if (emitted.has(node.id)) return;
const parent = node.parentId ? nodeById.get(node.parentId) : null;
if (parent && isFrameContainerNode(parent)) return;
emitNode(node);
});
return ordered;
}
export function attachNodesToContainingFrames(nodes: CanvasNode[], candidateIds: Set<string>) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const frames = nodes.filter(isFrameContainerNode);
if (frames.length === 0 || candidateIds.size === 0) return nodes;
return nodes.map((node) => {
if (!candidateIds.has(node.id) || node.layerRole === "clean-image" || isFrameContainerNode(node) || isBooleanShapeGroupNode(node) || isBooleanShapeOperandNode(node, nodes)) return node;
const parent = node.parentId ? nodeById.get(node.parentId) : null;
if (parent && !isFrameContainerNode(parent)) return node;
if (parent && candidateIds.has(parent.id) && nodeCenterInsideFrame(node, parent)) return node;
const containingFrame = [...frames].reverse().find((frame) => frame.id !== node.id && nodeCenterInsideFrame(node, frame));
const nextParentId = containingFrame?.id;
if ((node.parentId || undefined) === nextParentId) return node;
return { ...node, parentId: nextParentId };
});
}
function nodeCenterInsideFrame(node: CanvasNode, frame: CanvasNode) {
const centerX = node.x + node.width / 2;
const centerY = node.y + node.height / 2;
return centerX >= frame.x && centerX <= frame.x + frame.width && centerY >= frame.y && centerY <= frame.y + frame.height;
}
function childNodesByParentId(nodes: CanvasNode[]) {
const children = new Map<string, CanvasNode[]>();
nodes.forEach((node) => {
if (!node.parentId) return;
children.set(node.parentId, [...(children.get(node.parentId) ?? []), node]);
});
return children;
}
function descendantNodeIds(nodes: CanvasNode[], parentId: string) {
const children = childNodesByParentId(nodes);
const ids = new Set<string>();
const visit = (id: string) => {
(children.get(id) ?? []).forEach((child) => {
if (ids.has(child.id)) return;
ids.add(child.id);
visit(child.id);
});
};
visit(parentId);
return ids;
}
export function resizeFrameWithChildren(nodes: CanvasNode[], frameId: string, nextFrame: CanvasNode) {
const frame = nodes.find((node) => node.id === frameId);
if (!frame || !isFrameContainerNode(frame)) return nodes.map((node) => (node.id === frameId ? nextFrame : node));
const childIds = descendantNodeIds(nodes, frameId);
const scaleX = nextFrame.width / Math.max(frame.width, 1);
const scaleY = nextFrame.height / Math.max(frame.height, 1);
const textScale = Math.max(0.01, Math.sqrt(Math.max(scaleX, 0.01) * Math.max(scaleY, 0.01)));
return nodes.map((node) => {
if (node.id === frameId) return nextFrame;
if (!childIds.has(node.id)) return node;
const nextNode: CanvasNode = {
...node,
x: nextFrame.x + (node.x - frame.x) * scaleX,
y: nextFrame.y + (node.y - frame.y) * scaleY,
width: Math.max(1, node.width * scaleX),
height: Math.max(1, node.height * scaleY)
};
if (node.type === "text") {
nextNode.fontSize = node.fontSize ? Math.max(8, node.fontSize * textScale) : node.fontSize;
nextNode.letterSpacing = node.letterSpacing ? node.letterSpacing * textScale : node.letterSpacing;
nextNode.strokeWidth = node.strokeWidth ? node.strokeWidth * textScale : node.strokeWidth;
}
return nextNode;
});
}
export function resizeVisualNodeByHandle(node: CanvasNode, handle: ResizeHandle, pointerDelta: { dx: number; dy: number }) { export function resizeVisualNodeByHandle(node: CanvasNode, handle: ResizeHandle, pointerDelta: { dx: number; dy: number }) {
const aspect = node.width / Math.max(node.height, 1); const aspect = node.width / Math.max(node.height, 1);
const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0; const horizontalDirection = handle.includes("right") ? 1 : handle.includes("left") ? -1 : 0;
@@ -90,6 +220,44 @@ export function reorderNode(nodes: CanvasNode[], nodeId: string, action: NodeReo
return next; return next;
} }
export function reorderLayer(nodes: CanvasNode[], request: LayerReorderRequest) {
const activeContext = layerReorderContext(nodes, request.active);
const overContext = layerReorderContext(nodes, request.over);
if (!activeContext || !overContext || activeContext !== overContext) return nodes;
const activeIds = layerReorderItemNodeIds(nodes, request.active);
const overIds = layerReorderItemNodeIds(nodes, request.over);
if (activeIds.size === 0 || overIds.size === 0) return nodes;
if ([...overIds].some((id) => activeIds.has(id))) return nodes;
const movingNodes = nodes.filter((node) => activeIds.has(node.id));
const remainingNodes = nodes.filter((node) => !activeIds.has(node.id));
const overIndexes = remainingNodes.map((node, index) => (overIds.has(node.id) ? index : -1)).filter((index) => index >= 0);
if (overIndexes.length === 0) return nodes;
const insertIndex = request.placement === "before" ? Math.max(...overIndexes) + 1 : Math.min(...overIndexes);
return [...remainingNodes.slice(0, insertIndex), ...movingNodes, ...remainingNodes.slice(insertIndex)];
}
function layerReorderContext(nodes: CanvasNode[], item: LayerReorderItem) {
if (item.kind === "group") return "root";
const node = nodes.find((candidate) => candidate.id === item.id);
if (!node) return null;
if (!node.parentId) return "root";
return node.parentId;
}
function layerReorderItemNodeIds(nodes: CanvasNode[], item: LayerReorderItem) {
if (item.kind === "group") {
return new Set(nodes.filter((node) => node.parentId === item.id).map((node) => node.id));
}
const node = nodes.find((candidate) => candidate.id === item.id);
if (!node) return new Set<string>();
const ids = new Set([node.id]);
descendantNodeIds(nodes, node.id).forEach((id) => ids.add(id));
return ids;
}
export function toggleSetValue(current: Set<string>, value: string) { export function toggleSetValue(current: Set<string>, value: string) {
const next = new Set(current); const next = new Set(current);
if (next.has(value)) { if (next.has(value)) {
+121 -61
View File
@@ -36,11 +36,11 @@ import { visiblePromptText } from "@/ui/lib/promptImageReferences";
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters"; import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
import { CanvasColorPopover } from "./canvas/CanvasColorPopover"; import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry"; import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
import { alignNodes, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isRealCanvasImageNode, moveNodesByDelta, patchFrameStyleNode, removeNodeById, removeNodesById, reorderNode as reorderNodeOp, resizeVisualNodeByHandle, toggleSetValue, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { alignNodes, attachNodesToContainingFrames, autoLayoutAroundNode as autoLayoutAroundNodeOp, autoLayoutNodes, canResizeCanvasNode, canUseNodeContextMenu, cloneCanvasNode, distributeNodes, groupNodes, isCanvasPenStrokeNode, isFrameChildNode, isFrameContainerNode, isRealCanvasImageNode, moveNodesByDelta, nodeIdsWithFrameChildren, orderedCanvasNodesForRender, patchFrameStyleNode, removeNodesById, reorderLayer as reorderLayerOp, reorderNode as reorderNodeOp, resizeFrameWithChildren, resizeVisualNodeByHandle, toggleSetValues, ungroupNodes, updateNodeContent as updateNodeContentOp, updateNodeTransform as updateNodeTransformOp, visibleCanvasNodes as getVisibleCanvasNodes, type LayerReorderRequest } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame"; import { CanvasSelectionFrame } from "@/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame";
import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay"; import { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar"; import { FrameStyleToolbar } from "./canvas/FrameStyleToolbar";
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle"; import { defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor } from "./canvas/frameStyle";
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling"; import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling";
import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument"; import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence"; import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
@@ -925,7 +925,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}, [project?.id, projectId]); }, [project?.id, projectId]);
const visibleCanvasNodes = useMemo(() => getVisibleCanvasNodes(nodes, hiddenNodeIds), [hiddenNodeIds, nodes]); const visibleCanvasNodes = useMemo(() => getVisibleCanvasNodes(nodes, hiddenNodeIds), [hiddenNodeIds, nodes]);
const renderedCanvasNodes = useMemo(() => nodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, selectedIds]); const renderOrderedCanvasNodes = useMemo(() => orderedCanvasNodesForRender(nodes), [nodes]);
const renderedCanvasNodes = useMemo(() => renderOrderedCanvasNodes.filter((node) => !isBooleanShapeOperandNode(node, nodes) && (!hiddenNodeIds.has(node.id) || selectedIds.has(node.id))), [hiddenNodeIds, nodes, renderOrderedCanvasNodes, selectedIds]);
const imageModels = useMemo(() => getImageModels(locale), [locale]); const imageModels = useMemo(() => getImageModels(locale), [locale]);
const agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]); const agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]);
const filteredAgentHistoryThreads = useMemo(() => { const filteredAgentHistoryThreads = useMemo(() => {
@@ -1253,8 +1254,21 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
showToast(message, tone); showToast(message, tone);
}; };
const attachFrameChildren = (current: CanvasNode[], candidateIds: Set<string>) => attachNodesToContainingFrames(current, candidateIds);
const setNodesWithFrameAttachment = (updater: (current: CanvasNode[]) => { nodes: CanvasNode[]; candidateIds: Set<string> }) => {
setNodes((current) => {
const result = updater(current);
const next = attachFrameChildren(result.nodes, result.candidateIds);
nodesRef.current = next;
return next;
});
};
const frameAwareNodeIds = (nodeIds: Iterable<string>) => nodeIdsWithFrameChildren(nodesRef.current, new Set(nodeIds));
const findAnnotationTarget = (x: number, y: number, candidate?: CanvasNode) => { const findAnnotationTarget = (x: number, y: number, candidate?: CanvasNode) => {
const visualNodes = nodesRef.current.filter((node) => (node.type === "image" || node.type === "frame") && node.status !== "generating"); const visualNodes = orderedCanvasNodesForRender(nodesRef.current).filter((node) => (node.type === "image" || node.type === "frame") && node.status !== "generating");
if (candidate && visualNodes.some((node) => node.id === candidate.id) && pointInsideNode(x, y, candidate)) return candidate; if (candidate && visualNodes.some((node) => node.id === candidate.id) && pointInsideNode(x, y, candidate)) return candidate;
return [...visualNodes].reverse().find((node) => pointInsideNode(x, y, node)) ?? null; return [...visualNodes].reverse().find((node) => pointInsideNode(x, y, node)) ?? null;
}; };
@@ -1292,12 +1306,17 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
status: "draft", status: "draft",
layerRole: "manual-frame", layerRole: "manual-frame",
opacity: 1, opacity: 1,
fillColor: defaultFrameFillColor, fillColor: "transparent",
strokeColor: defaultFrameStrokeColor, strokeColor: defaultFrameStrokeColor,
strokeWidth: defaultFrameStrokeWidth, strokeWidth: defaultFrameStrokeWidth,
strokeStyle: defaultFrameStrokeStyle strokeStyle: defaultFrameStrokeStyle
}; };
setNodes((current) => [...current, node]); setNodes((current) => {
const candidateIds = new Set(current.map((item) => item.id));
const next = attachFrameChildren([...current, node], candidateIds);
nodesRef.current = next;
return next;
});
selectOnlyNode(node.id); selectOnlyNode(node.id);
setActiveTool("select"); setActiveTool("select");
setMode("select"); setMode("select");
@@ -1314,11 +1333,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
kind: selectedShapeKind, kind: selectedShapeKind,
draft draft
}); });
setNodes((current) => { setNodesWithFrameAttachment((current) => ({
const next = [...current, node]; nodes: [...current, node],
nodesRef.current = next; candidateIds: new Set([node.id])
return next; }));
});
selectOnlyNode(node.id); selectOnlyNode(node.id);
setActiveTool("select"); setActiveTool("select");
setMode("select"); setMode("select");
@@ -1342,11 +1360,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
onDraftChange: setPenDraft, onDraftChange: setPenDraft,
onCommit: (draft) => { onCommit: (draft) => {
const node = createPenStrokeNode(draft, t("newPenStrokeNode")); const node = createPenStrokeNode(draft, t("newPenStrokeNode"));
setNodes((current) => { setNodesWithFrameAttachment((current) => ({
const next = [...current, node]; nodes: [...current, node],
nodesRef.current = next; candidateIds: new Set([node.id])
return next; }));
});
setDirty(true); setDirty(true);
} }
}); });
@@ -1598,24 +1615,30 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
activeTool === "select" && activeTool === "select" &&
mode === "select" && mode === "select" &&
!lockedNodeIds.has(node.id) && !lockedNodeIds.has(node.id) &&
!node.parentId &&
!editingTextNodeId; !editingTextNodeId;
const nodeGroupIds = node.parentId ? nodesRef.current.filter((item) => item.parentId === node.parentId).map((item) => item.id) : []; const frameChild = isFrameChildNode(node, nodesRef.current);
const nodeGroupIds = node.parentId && !frameChild ? nodesRef.current.filter((item) => item.parentId === node.parentId).map((item) => item.id) : [];
const useExistingSelection = selectedIds.has(node.id) && selectedIds.size > 1; const useExistingSelection = selectedIds.has(node.id) && selectedIds.size > 1;
const groupDrag = useExistingSelection || nodeGroupIds.length > 1; const groupDrag = useExistingSelection || nodeGroupIds.length > 1;
const dragIds = groupDrag ? new Set(useExistingSelection ? selectedIds : nodeGroupIds) : new Set([node.id]); const selectedDragIds = groupDrag ? new Set(useExistingSelection ? selectedIds : nodeGroupIds) : new Set([node.id]);
const dragIds = nodeIdsWithFrameChildren(nodesRef.current, selectedDragIds);
if (groupDrag) { if (groupDrag) {
selectNodes(Array.from(dragIds)); selectNodes(Array.from(selectedDragIds));
} else { } else {
selectOnlyNode(node.id); selectOnlyNode(node.id);
} }
if (lockedNodeIds.has(node.id)) return; if (lockedNodeIds.has(node.id)) return;
const dragNodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
const movesWithSelectedFrame = (item: CanvasNode) => {
const parent = item.parentId ? dragNodeById.get(item.parentId) : null;
return Boolean(parent && selectedDragIds.has(parent.id) && isFrameContainerNode(parent));
};
const start = { const start = {
pointerX: event.clientX, pointerX: event.clientX,
pointerY: event.clientY, pointerY: event.clientY,
positions: nodesRef.current positions: nodesRef.current
.filter((item) => dragIds.has(item.id) && !lockedNodeIds.has(item.id)) .filter((item) => dragIds.has(item.id) && (!lockedNodeIds.has(item.id) || movesWithSelectedFrame(item)))
.map((item) => ({ id: item.id, x: item.x, y: item.y })), .map((item) => ({ id: item.id, x: item.x, y: item.y })),
scale: viewport.k scale: viewport.k
}; };
@@ -1628,7 +1651,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const nextNodes = moveNodesByDelta(nodesRef.current, start.positions, dx, dy); const nextNodes = moveNodesByDelta(nodesRef.current, start.positions, dx, dy);
const movedNodes = nextNodes.filter((item) => dragIds.has(item.id) && !lockedNodeIds.has(item.id)); const movedNodes = nextNodes.filter((item) => selectedDragIds.has(item.id) && !lockedNodeIds.has(item.id));
nodesRef.current = nextNodes; nodesRef.current = nextNodes;
setNodes(nextNodes); setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current)); setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current));
@@ -1643,6 +1666,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setEditingTextNodeId(node.id); setEditingTextNodeId(node.id);
return; return;
} }
if (dragged) {
const nextNodes = attachFrameChildren(nodesRef.current, dragIds);
nodesRef.current = nextNodes;
setNodes(nextNodes);
}
if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) { if (dragged && Array.from(dragIds).some((nodeId) => nodesRef.current.find((item) => item.id === nodeId)?.layerRole === "mockup-print")) {
void refreshMockupPrintTextures(Array.from(dragIds)); void refreshMockupPrintTextures(Array.from(dragIds));
return; return;
@@ -1702,6 +1730,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]); setAlignmentGuides([]);
const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id]));
nodesRef.current = nextNodes;
setNodes(nextNodes);
if (node.layerRole === "mockup-print") { if (node.layerRole === "mockup-print") {
void refreshMockupPrintTextures([node.id]); void refreshMockupPrintTextures([node.id]);
return; return;
@@ -1734,8 +1765,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const dx = (moveEvent.clientX - start.pointerX) / start.scale; const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale; const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const resizedNode = resizeVisualNodeByHandle(node, handle, { dx, dy }); const resizedNode = resizeVisualNodeByHandle(node, handle, { dx, dy });
nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item)); const nextNodes = isFrameContainerNode(node) ? resizeFrameWithChildren(nodesRef.current, node.id, resizedNode) : nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item));
setNodes((current) => current.map((item) => (item.id === node.id ? resizedNode : item))); nodesRef.current = nextNodes;
setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current)); setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current));
setDirty(true); setDirty(true);
}; };
@@ -1744,6 +1776,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]); setAlignmentGuides([]);
if (!isFrameContainerNode(node)) {
const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id]));
nodesRef.current = nextNodes;
setNodes(nextNodes);
}
scheduleCanvasSave(); scheduleCanvasSave();
}; };
@@ -1848,9 +1885,12 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const updateFrameStyle = (nodeId: string, patch: FrameStylePatch) => { const updateFrameStyle = (nodeId: string, patch: FrameStylePatch) => {
const applyPatch = (item: CanvasNode) => (item.id === nodeId ? patchFrameStyleNode(item, patch) : item); const frame = nodesRef.current.find((item) => item.id === nodeId);
nodesRef.current = nodesRef.current.map(applyPatch); if (!frame) return;
setNodes((current) => current.map(applyPatch)); const nextFrame = patchFrameStyleNode(frame, patch);
const nextNodes = isFrameContainerNode(frame) ? resizeFrameWithChildren(nodesRef.current, nodeId, nextFrame) : nodesRef.current.map((item) => (item.id === nodeId ? nextFrame : item));
nodesRef.current = nextNodes;
setNodes(nextNodes);
setDirty(true); setDirty(true);
}; };
@@ -1884,25 +1924,26 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const removeNode = (nodeId: string) => { const removeNode = (nodeId: string) => {
nodesRef.current = removeNodeById(nodesRef.current, nodeId); const ids = frameAwareNodeIds([nodeId]);
setNodes((current) => removeNodeById(current, nodeId)); nodesRef.current = removeNodesById(nodesRef.current, ids);
setEditingTextNodeId((current) => (current === nodeId ? null : current)); setNodes((current) => removeNodesById(current, ids));
setEditingTextNodeId((current) => (current && ids.has(current) ? null : current));
setHiddenNodeIds((current) => { setHiddenNodeIds((current) => {
const next = new Set(current); const next = new Set(current);
next.delete(nodeId); ids.forEach((id) => next.delete(id));
return next; return next;
}); });
setLockedNodeIds((current) => { setLockedNodeIds((current) => {
const next = new Set(current); const next = new Set(current);
next.delete(nodeId); ids.forEach((id) => next.delete(id));
return next; return next;
}); });
setSelectedIds((current) => { setSelectedIds((current) => {
const next = new Set(current); const next = new Set(current);
next.delete(nodeId); ids.forEach((id) => next.delete(id));
return next; return next;
}); });
if (selectedId === nodeId) selectOnlyNode(null); if (selectedId && ids.has(selectedId)) selectOnlyNode(null);
setDirty(true); setDirty(true);
}; };
@@ -1928,7 +1969,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const duplicateNode = (node: CanvasNode) => { const duplicateNode = (node: CanvasNode) => {
const copy = cloneCanvasNode(node, cryptoFallbackId()); const copy = cloneCanvasNode(node, cryptoFallbackId());
setNodes((current) => [...current, copy]); setNodesWithFrameAttachment((current) => ({
nodes: [...current, copy],
candidateIds: new Set([copy.id])
}));
selectOnlyNode(copy.id); selectOnlyNode(copy.id);
setDirty(true); setDirty(true);
}; };
@@ -1940,29 +1984,44 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => { const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
setNodes((current) => reorderNodeOp(current, nodeId, action)); setNodes((current) => {
const next = reorderNodeOp(current, nodeId, action);
nodesRef.current = next;
return next;
});
setDirty(true);
};
const reorderLayer = (request: LayerReorderRequest) => {
setNodes((current) => {
const next = reorderLayerOp(current, request);
nodesRef.current = next;
return next;
});
setDirty(true); setDirty(true);
}; };
const toggleLayerHidden = (nodeId: string) => { const toggleLayerHidden = (nodeId: string) => {
setHiddenNodeIds((current) => toggleSetValue(current, nodeId)); const ids = Array.from(frameAwareNodeIds([nodeId]));
setHiddenNodeIds((current) => toggleSetValues(current, ids));
setDirty(true); setDirty(true);
}; };
const toggleLayerLocked = (nodeId: string) => { const toggleLayerLocked = (nodeId: string) => {
setLockedNodeIds((current) => toggleSetValue(current, nodeId)); const ids = Array.from(frameAwareNodeIds([nodeId]));
setLockedNodeIds((current) => toggleSetValues(current, ids));
setDirty(true); setDirty(true);
}; };
const toggleLayersHidden = (nodeIds: string[]) => { const toggleLayersHidden = (nodeIds: string[]) => {
if (nodeIds.length === 0) return; if (nodeIds.length === 0) return;
setHiddenNodeIds((current) => toggleSetValues(current, nodeIds)); setHiddenNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds))));
setDirty(true); setDirty(true);
}; };
const toggleLayersLocked = (nodeIds: string[]) => { const toggleLayersLocked = (nodeIds: string[]) => {
if (nodeIds.length === 0) return; if (nodeIds.length === 0) return;
setLockedNodeIds((current) => toggleSetValues(current, nodeIds)); setLockedNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds))));
setDirty(true); setDirty(true);
}; };
@@ -2116,7 +2175,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const deleteSelectedNodes = () => { const deleteSelectedNodes = () => {
const ids = new Set(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id)); const ids = frameAwareNodeIds(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id));
if (ids.size === 0) return; if (ids.size === 0) return;
nodesRef.current = removeNodesById(nodesRef.current, ids); nodesRef.current = removeNodesById(nodesRef.current, ids);
setNodes((current) => removeNodesById(current, ids)); setNodes((current) => removeNodesById(current, ids));
@@ -2163,11 +2222,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
textDecoration: "none", textDecoration: "none",
textTransform: "none" textTransform: "none"
}); });
setNodes((current) => { setNodesWithFrameAttachment((current) => ({
const next = [...current, node]; nodes: [...current, node],
nodesRef.current = next; candidateIds: new Set([node.id])
return next; }));
});
selectOnlyNode(node.id); selectOnlyNode(node.id);
setDirty(true); setDirty(true);
return true; return true;
@@ -2335,7 +2393,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
tone: "natural", tone: "natural",
status: "draft" status: "draft"
}; };
setNodes((current) => [...current, node]); setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
selectOnlyNode(node.id); selectOnlyNode(node.id);
setDirty(true); setDirty(true);
}; };
@@ -2368,11 +2429,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setComposerMode("image"); setComposerMode("image");
setActiveTool("select"); setActiveTool("select");
setMode("select"); setMode("select");
setNodes((current) => { setNodesWithFrameAttachment((current) => ({
const next = [...current, node]; nodes: [...current, node],
nodesRef.current = next; candidateIds: new Set([node.id])
return next; }));
});
selectOnlyNode(node.id); selectOnlyNode(node.id);
setDirty(true); setDirty(true);
}; };
@@ -2442,11 +2502,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
textDecoration: "none", textDecoration: "none",
textTransform: "none" textTransform: "none"
}; };
setNodes((current) => { setNodesWithFrameAttachment((current) => ({
const next = [...current, node]; nodes: [...current, node],
nodesRef.current = next; candidateIds: new Set([node.id])
return next; }));
});
selectOnlyNode(node.id); selectOnlyNode(node.id);
setEditingTextNodeId(node.id); setEditingTextNodeId(node.id);
setDirty(true); setDirty(true);
@@ -3270,7 +3329,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
} }
const optimisticNodes = preparedUploads.map((item) => item.node); const optimisticNodes = preparedUploads.map((item) => item.node);
const nextNodesWithUploads = [...nodesRef.current, ...optimisticNodes]; const nextNodesWithUploads = attachFrameChildren([...nodesRef.current, ...optimisticNodes], new Set(optimisticNodes.map((node) => node.id)));
nodesRef.current = nextNodesWithUploads; nodesRef.current = nextNodesWithUploads;
setNodes(nextNodesWithUploads); setNodes(nextNodesWithUploads);
selectNodes(optimisticNodes.map((node) => node.id)); selectNodes(optimisticNodes.map((node) => node.id));
@@ -3435,6 +3494,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
onToggleLocked={toggleLayerLocked} onToggleLocked={toggleLayerLocked}
onToggleHiddenMany={toggleLayersHidden} onToggleHiddenMany={toggleLayersHidden}
onToggleLockedMany={toggleLayersLocked} onToggleLockedMany={toggleLayersLocked}
onReorderLayer={reorderLayer}
onClose={() => setLayersPanelOpen(false)} onClose={() => setLayersPanelOpen(false)}
/> />
)} )}
@@ -4159,9 +4219,9 @@ function CanvasNodeView({
selectionScale={selectionScale} selectionScale={selectionScale}
transform={transform} transform={transform}
onResizePointerDown={onResizePointerDown} onResizePointerDown={onResizePointerDown}
onTitleRename={node.type === "image" ? onTitleRename : undefined} onTitleRename={onTitleRename}
renameLabel={t("renameImageNode")} renameLabel={t("renameLayer")}
titlePlaceholder={t("imageTitlePlaceholder")} titlePlaceholder={t("layerTitlePlaceholder")}
/> />
)} )}
{selected && lineShapeNode && !locked && ( {selected && lineShapeNode && !locked && (
+47 -6
View File
@@ -2616,9 +2616,10 @@ button:disabled {
} }
.layer-row { .layer-row {
position: relative;
height: 46px; height: 46px;
display: grid; display: grid;
grid-template-columns: 32px minmax(0, 1fr) 26px 26px; grid-template-columns: 18px 30px minmax(0, 1fr) 24px 24px;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 4px 6px; padding: 4px 6px;
@@ -2627,6 +2628,18 @@ button:disabled {
cursor: default; cursor: default;
} }
.layer-row.is-dragging {
opacity: 0.42;
}
.layer-row.drop-before {
box-shadow: inset 0 2px 0 #2f80ff;
}
.layer-row.drop-after {
box-shadow: inset 0 -2px 0 #2f80ff;
}
.layer-row:hover, .layer-row:hover,
.layer-row.selected { .layer-row.selected {
background: #edf0f4; background: #edf0f4;
@@ -2638,12 +2651,8 @@ button:disabled {
font-weight: 760; font-weight: 760;
} }
.layer-row.boolean-layer-group-row {
grid-template-columns: 14px 32px minmax(0, 1fr) 26px 26px;
}
.layer-row.nested { .layer-row.nested {
grid-template-columns: 20px 28px minmax(0, 1fr) 24px 24px; grid-template-columns: 20px 18px 28px minmax(0, 1fr) 24px 24px;
padding-left: 12px; padding-left: 12px;
} }
@@ -2654,6 +2663,38 @@ button:disabled {
content: ""; content: "";
} }
.layer-disclosure,
.layer-drag-handle {
width: 18px;
height: 26px;
display: grid;
place-items: center;
border: 0;
color: #8a92a0;
background: transparent;
}
.layer-disclosure {
border-radius: 6px;
}
.layer-disclosure:not(:disabled):hover {
color: #20242b;
background: #e4e8ee;
}
.layer-disclosure:disabled {
opacity: 0.32;
}
.layer-drag-handle {
opacity: 0.42;
}
.layer-row:hover .layer-drag-handle {
opacity: 0.8;
}
.layer-row.is-hidden { .layer-row.is-hidden {
opacity: 0.58; opacity: 0.58;
} }