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;
metadata: {
artifact_id?: string;
bbox?: number[];
format?: string;
generator_name?: string;
height?: number;
label?: 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 = {
id: string;
deleted: boolean;
+3
View File
@@ -66,6 +66,7 @@
"recentProjects": "Recent Projects",
"viewAll": "View all",
"collapse": "Collapse",
"expandLayer": "Expand layer",
"newProject": "New Project",
"continueEditing": "Continue",
"inspiration": "Inspiration",
@@ -126,6 +127,8 @@
"renameCanvasTitle": "Rename canvas",
"renameImageNode": "Rename image",
"imageTitlePlaceholder": "Image name",
"renameLayer": "Rename layer",
"layerTitlePlaceholder": "Layer name",
"projectTitlePlaceholder": "Name this canvas",
"select": "Select",
"pan": "Move",
+3
View File
@@ -66,6 +66,7 @@
"recentProjects": "最近项目",
"viewAll": "查看全部",
"collapse": "收起",
"expandLayer": "展开图层",
"newProject": "新建项目",
"continueEditing": "继续编辑",
"inspiration": "灵感发现",
@@ -126,6 +127,8 @@
"renameCanvasTitle": "重命名画布",
"renameImageNode": "重命名图片",
"imageTitlePlaceholder": "输入图片名称",
"renameLayer": "重命名图层",
"layerTitlePlaceholder": "输入图层名称",
"projectTitlePlaceholder": "输入画布名称",
"select": "选择",
"pan": "移动",
@@ -11,6 +11,7 @@ import type {
DeleteProjectsResponse,
ExtractNodeTextResponse,
GeneratorTaskResponse,
GeneratorTaskSubmitResponse,
GenerateResult,
MergeLayersRequest,
NodeActionRequest,
@@ -358,6 +359,18 @@ export const designGateway = {
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") {
return request<RecognizeObjectResponse>("/api/context/vision/recognize_object", {
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)) {
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>();
@@ -5,6 +5,13 @@ import { nodeBounds, normalizeRotation, type NodeAlignment, type NodeDistributio
export type NodeReorderAction = "forward" | "backward" | "front" | "back";
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>) {
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 }) {
const aspect = node.width / Math.max(node.height, 1);
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;
}
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) {
const next = new Set(current);
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 { 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 { 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 { beginFrameDrawing, FrameDraftOverlay, type FrameDraft, type FrameWorldRect } from "./canvas/FrameToolOverlay";
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 { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
@@ -925,7 +925,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}, [project?.id, projectId]);
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 agentHistoryThreads = useMemo(() => agentHistoryListItems(agentThreads, project, imageGeneratorThreadIdsRef.current), [agentThreads, project]);
const filteredAgentHistoryThreads = useMemo(() => {
@@ -1253,8 +1254,21 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
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 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;
return [...visualNodes].reverse().find((node) => pointInsideNode(x, y, node)) ?? null;
};
@@ -1292,12 +1306,17 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
status: "draft",
layerRole: "manual-frame",
opacity: 1,
fillColor: defaultFrameFillColor,
fillColor: "transparent",
strokeColor: defaultFrameStrokeColor,
strokeWidth: defaultFrameStrokeWidth,
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);
setActiveTool("select");
setMode("select");
@@ -1314,11 +1333,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
kind: selectedShapeKind,
draft
});
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
selectOnlyNode(node.id);
setActiveTool("select");
setMode("select");
@@ -1342,11 +1360,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
onDraftChange: setPenDraft,
onCommit: (draft) => {
const node = createPenStrokeNode(draft, t("newPenStrokeNode"));
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
setDirty(true);
}
});
@@ -1598,24 +1615,30 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
activeTool === "select" &&
mode === "select" &&
!lockedNodeIds.has(node.id) &&
!node.parentId &&
!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 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) {
selectNodes(Array.from(dragIds));
selectNodes(Array.from(selectedDragIds));
} else {
selectOnlyNode(node.id);
}
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 = {
pointerX: event.clientX,
pointerY: event.clientY,
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 })),
scale: viewport.k
};
@@ -1628,7 +1651,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
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;
setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides(movedNodes, nextNodes, viewportRef.current));
@@ -1643,6 +1666,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setEditingTextNodeId(node.id);
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")) {
void refreshMockupPrintTextures(Array.from(dragIds));
return;
@@ -1702,6 +1730,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id]));
nodesRef.current = nextNodes;
setNodes(nextNodes);
if (node.layerRole === "mockup-print") {
void refreshMockupPrintTextures([node.id]);
return;
@@ -1734,8 +1765,9 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const dx = (moveEvent.clientX - start.pointerX) / start.scale;
const dy = (moveEvent.clientY - start.pointerY) / start.scale;
const resizedNode = resizeVisualNodeByHandle(node, handle, { dx, dy });
nodesRef.current = nodesRef.current.map((item) => (item.id === node.id ? resizedNode : item));
setNodes((current) => 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));
nodesRef.current = nextNodes;
setNodes(nextNodes);
setAlignmentGuides(buildAlignmentGuides([resizedNode], nodesRef.current, viewportRef.current));
setDirty(true);
};
@@ -1744,6 +1776,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
setAlignmentGuides([]);
if (!isFrameContainerNode(node)) {
const nextNodes = attachFrameChildren(nodesRef.current, new Set([node.id]));
nodesRef.current = nextNodes;
setNodes(nextNodes);
}
scheduleCanvasSave();
};
@@ -1848,9 +1885,12 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
const updateFrameStyle = (nodeId: string, patch: FrameStylePatch) => {
const applyPatch = (item: CanvasNode) => (item.id === nodeId ? patchFrameStyleNode(item, patch) : item);
nodesRef.current = nodesRef.current.map(applyPatch);
setNodes((current) => current.map(applyPatch));
const frame = nodesRef.current.find((item) => item.id === nodeId);
if (!frame) return;
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);
};
@@ -1884,25 +1924,26 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
const removeNode = (nodeId: string) => {
nodesRef.current = removeNodeById(nodesRef.current, nodeId);
setNodes((current) => removeNodeById(current, nodeId));
setEditingTextNodeId((current) => (current === nodeId ? null : current));
const ids = frameAwareNodeIds([nodeId]);
nodesRef.current = removeNodesById(nodesRef.current, ids);
setNodes((current) => removeNodesById(current, ids));
setEditingTextNodeId((current) => (current && ids.has(current) ? null : current));
setHiddenNodeIds((current) => {
const next = new Set(current);
next.delete(nodeId);
ids.forEach((id) => next.delete(id));
return next;
});
setLockedNodeIds((current) => {
const next = new Set(current);
next.delete(nodeId);
ids.forEach((id) => next.delete(id));
return next;
});
setSelectedIds((current) => {
const next = new Set(current);
next.delete(nodeId);
ids.forEach((id) => next.delete(id));
return next;
});
if (selectedId === nodeId) selectOnlyNode(null);
if (selectedId && ids.has(selectedId)) selectOnlyNode(null);
setDirty(true);
};
@@ -1928,7 +1969,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const duplicateNode = (node: CanvasNode) => {
const copy = cloneCanvasNode(node, cryptoFallbackId());
setNodes((current) => [...current, copy]);
setNodesWithFrameAttachment((current) => ({
nodes: [...current, copy],
candidateIds: new Set([copy.id])
}));
selectOnlyNode(copy.id);
setDirty(true);
};
@@ -1940,29 +1984,44 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
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);
};
const toggleLayerHidden = (nodeId: string) => {
setHiddenNodeIds((current) => toggleSetValue(current, nodeId));
const ids = Array.from(frameAwareNodeIds([nodeId]));
setHiddenNodeIds((current) => toggleSetValues(current, ids));
setDirty(true);
};
const toggleLayerLocked = (nodeId: string) => {
setLockedNodeIds((current) => toggleSetValue(current, nodeId));
const ids = Array.from(frameAwareNodeIds([nodeId]));
setLockedNodeIds((current) => toggleSetValues(current, ids));
setDirty(true);
};
const toggleLayersHidden = (nodeIds: string[]) => {
if (nodeIds.length === 0) return;
setHiddenNodeIds((current) => toggleSetValues(current, nodeIds));
setHiddenNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds))));
setDirty(true);
};
const toggleLayersLocked = (nodeIds: string[]) => {
if (nodeIds.length === 0) return;
setLockedNodeIds((current) => toggleSetValues(current, nodeIds));
setLockedNodeIds((current) => toggleSetValues(current, Array.from(frameAwareNodeIds(nodeIds))));
setDirty(true);
};
@@ -2116,7 +2175,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
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;
nodesRef.current = removeNodesById(nodesRef.current, ids);
setNodes((current) => removeNodesById(current, ids));
@@ -2163,11 +2222,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
textDecoration: "none",
textTransform: "none"
});
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
selectOnlyNode(node.id);
setDirty(true);
return true;
@@ -2335,7 +2393,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
tone: "natural",
status: "draft"
};
setNodes((current) => [...current, node]);
setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
selectOnlyNode(node.id);
setDirty(true);
};
@@ -2368,11 +2429,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setComposerMode("image");
setActiveTool("select");
setMode("select");
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
selectOnlyNode(node.id);
setDirty(true);
};
@@ -2442,11 +2502,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
textDecoration: "none",
textTransform: "none"
};
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
setNodesWithFrameAttachment((current) => ({
nodes: [...current, node],
candidateIds: new Set([node.id])
}));
selectOnlyNode(node.id);
setEditingTextNodeId(node.id);
setDirty(true);
@@ -3270,7 +3329,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}
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;
setNodes(nextNodesWithUploads);
selectNodes(optimisticNodes.map((node) => node.id));
@@ -3435,6 +3494,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
onToggleLocked={toggleLayerLocked}
onToggleHiddenMany={toggleLayersHidden}
onToggleLockedMany={toggleLayersLocked}
onReorderLayer={reorderLayer}
onClose={() => setLayersPanelOpen(false)}
/>
)}
@@ -4159,9 +4219,9 @@ function CanvasNodeView({
selectionScale={selectionScale}
transform={transform}
onResizePointerDown={onResizePointerDown}
onTitleRename={node.type === "image" ? onTitleRename : undefined}
renameLabel={t("renameImageNode")}
titlePlaceholder={t("imageTitlePlaceholder")}
onTitleRename={onTitleRename}
renameLabel={t("renameLayer")}
titlePlaceholder={t("layerTitlePlaceholder")}
/>
)}
{selected && lineShapeNode && !locked && (
+47 -6
View File
@@ -2616,9 +2616,10 @@ button:disabled {
}
.layer-row {
position: relative;
height: 46px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 26px 26px;
grid-template-columns: 18px 30px minmax(0, 1fr) 24px 24px;
align-items: center;
gap: 7px;
padding: 4px 6px;
@@ -2627,6 +2628,18 @@ button:disabled {
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.selected {
background: #edf0f4;
@@ -2638,12 +2651,8 @@ button:disabled {
font-weight: 760;
}
.layer-row.boolean-layer-group-row {
grid-template-columns: 14px 32px minmax(0, 1fr) 26px 26px;
}
.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;
}
@@ -2654,6 +2663,38 @@ button:disabled {
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 {
opacity: 0.58;
}