feat(canvas): per-generator target size, placement and draft preservation

Persist each image-generator node's target resolution in metadata and
surface it in the selection frame and node preview. Place new generators
without overlapping existing ones, keep local draft generators when a
compressed snapshot omits them, and add a dedicated selection-frame
variant plus scale-aware preview styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 01:46:16 +08:00
parent 82683c40ea
commit cbf4e21e2b
6 changed files with 236 additions and 36 deletions
+93 -16
View File
@@ -46,7 +46,7 @@ import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream";
import { imageGeneratorPlaceholderSize } from "./canvas/imageGeneratorSizing";
import { imageGeneratorPlaceholderSize, imageGeneratorTargetSizeFromNode, imageGeneratorTargetSizeFromSettings, serializeImageGeneratorTargetSize, type ImageGeneratorTargetSize } from "./canvas/imageGeneratorSizing";
import { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage } from "./canvas/nodeReferenceImages";
import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport";
import { beginPenStroke, createPenStrokeNode, PenStrokeDraftOverlay, PenToolControls, type PenDraft, type PenSettings } from "@/ui/pages/CanvasWorkspace/canvas/PenTool";
@@ -707,12 +707,13 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}, [editingTextNodeId, nodes]);
const applyProject = useCallback((nextProject: Project, options: { preserveMessages?: boolean } = {}) => {
applyProjectDocument(nextProject);
const mergedProject = preserveLocalDraftImageGenerators(nextProject, nodesRef.current);
applyProjectDocument(mergedProject);
if (!options.preserveMessages) {
setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
setMessages(visibleAgentMessagesForThread(mergedProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
}
setEditingTextNodeId(null);
}, [applyProjectDocument]);
}, [applyProjectDocument, nodesRef]);
const appendThinkingMessage = useCallback((message: AgentMessage) => {
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
@@ -937,7 +938,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null;
const selectedImageGeneratorBusy = imageGeneratorGenerating || selectedImageGeneratorNode?.status === "generating";
const selectedImageGeneratorBusy = selectedImageGeneratorNode?.status === "generating";
const selectedPenStrokeNode = selectedControlNode && isCanvasPenStrokeNode(selectedControlNode) ? selectedControlNode : null;
const selectedFrameNode = selectedControlNode?.type === "frame" && selectedControlNode.layerRole === "manual-frame" ? selectedControlNode : null;
const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null;
@@ -962,6 +963,16 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
height: selectedImageGeneratorNode.height * viewport.k
};
}, [selectedImageGeneratorNode, viewport]);
useEffect(() => {
if (!selectedImageGeneratorNode) return;
const targetSize = imageGeneratorTargetSizeFromNode(selectedImageGeneratorNode);
setComposerImageSettings((current) => {
if (current.width === targetSize.width && current.height === targetSize.height) return current;
return { ...current, width: targetSize.width, height: targetSize.height, ratio: "auto" };
});
}, [selectedImageGeneratorNode?.id]);
const selectedFrameBounds = useMemo<NodeScreenBounds | null>(() => {
if (!selectedFrameNode) return null;
return {
@@ -2105,6 +2116,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));
if (ids.size === 0) return;
nodesRef.current = removeNodesById(nodesRef.current, ids);
setNodes((current) => removeNodesById(current, ids));
setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
setLockedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
@@ -2227,6 +2239,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const primary = isPrimaryShortcut(event);
const selected = selectedNode;
const selectedHandlers = selected && !lockedNodeIds.has(selected.id) && canUseNodeContextMenu(selected) ? createNodeContextMenuHandlers(selected) : null;
const selectedCanDelete = selected && !lockedNodeIds.has(selected.id) && !hiddenNodeIds.has(selected.id);
const run = (action: (() => void) | undefined, prevent = true) => {
if (!action) return false;
if (prevent) event.preventDefault();
@@ -2276,7 +2289,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
if (primary && event.shiftKey && key === "h") return void run(selectedHandlers?.toggleHidden);
if (primary && event.shiftKey && key === "l") return void run(selectedHandlers?.toggleLocked);
if ((event.key === "Backspace" || event.key === "Delete") && selectedNodes.length > 1) return void run(deleteSelectedNodes);
if ((event.key === "Backspace" || event.key === "Delete") && selected) return void run(selectedHandlers?.deleteNode);
if ((event.key === "Backspace" || event.key === "Delete") && selected) return void run(selectedHandlers?.deleteNode ?? (selectedCanDelete ? () => removeNode(selected.id) : undefined));
};
window.addEventListener("keydown", handleKeyDown);
@@ -2335,18 +2348,20 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
};
const origin = point ?? center;
const size = imageGeneratorPlaceholderSize(composerImageSettings, viewport.k, { width: stageWidth, height: stageHeight });
const position = point ? { x: origin.x - size.width / 2, y: origin.y - size.height / 2 } : nextImageGeneratorPosition(origin, size, nodesRef.current, viewport.k);
const node: CanvasNode = {
id: cryptoFallbackId(),
type: "image",
title: t("imageGeneratorNode"),
x: origin.x - size.width / 2,
y: origin.y - size.height / 2,
x: position.x,
y: position.y,
width: size.width,
height: size.height,
content: "",
tone: "image-generator",
status: "draft",
layerRole: "image-generator"
layerRole: "image-generator",
imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings)
};
setComposerMode("image");
setActiveTool("select");
@@ -2375,7 +2390,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
x: centerX - size.width / 2,
y: centerY - size.height / 2,
width: size.width,
height: size.height
height: size.height,
imageAdjustments: serializeImageGeneratorTargetSize(settings, node.imageAdjustments)
};
});
nodesRef.current = next;
@@ -2577,7 +2593,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setNodes((current) => {
const next = current.map((node) =>
node.id === selectedGeneratorNode.id && node.type === "image" && node.layerRole === "image-generator"
? { ...node, content: lockedPrompt, status: "generating" }
? { ...node, content: lockedPrompt, status: "generating", imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings, node.imageAdjustments) }
: node
);
nodesRef.current = next;
@@ -3437,6 +3453,12 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const locked = lockedNodeIds.has(node.id);
const canOperateNode = !locked;
const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id;
const imageGeneratorTargetSize =
node.type === "image" && node.layerRole === "image-generator"
? node.id === selectedImageGeneratorNode?.id
? imageGeneratorTargetSizeFromSettings(composerImageSettings)
: imageGeneratorTargetSizeFromNode(node)
: undefined;
return (
<CanvasNodeView
key={node.id}
@@ -3455,6 +3477,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}}
selectionScale={viewport.k}
viewportScale={viewport.k}
imageGeneratorTargetSize={imageGeneratorTargetSize}
hasClipboard
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
onPointerDown={(event) => handleNodePointerDown(event, node)}
@@ -3475,7 +3498,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
<FrameDraftOverlay draft={frameDraft} label={t("newFrameNode")} />
<ShapeDraftOverlay draft={shapeDraft} kind={selectedShapeKind} label={shapeTitle(selectedShapeKind, t)} />
<PenStrokeDraftOverlay draft={penDraft} />
{selectedImageGeneratorBounds && (
{selectedImageGeneratorBounds && selectedImageGeneratorNode?.status === "draft" && (
<ImageGeneratorFloatingComposer
bounds={selectedImageGeneratorBounds}
stageWidth={canvasStageSize.width}
@@ -3484,7 +3507,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
selectedModel={selectedCanvasModel}
settings={composerImageSettings}
generating={selectedImageGeneratorBusy}
lockedPrompt={selectedImageGeneratorNode?.status === "generating" ? cleanImageGeneratorPrompt(selectedImageGeneratorNode.content) : ""}
lockedPrompt=""
onSettingsChange={updateComposerImageSettings}
onModelChange={setSelectedCanvasModelId}
onUploadFile={uploadReferenceToImageGenerator}
@@ -4006,6 +4029,7 @@ function CanvasNodeView({
transform,
selectionScale,
viewportScale,
imageGeneratorTargetSize,
hasClipboard,
contextMenuHandlers,
onPointerDown,
@@ -4027,6 +4051,7 @@ function CanvasNodeView({
transform: NodeTransform;
selectionScale: number;
viewportScale: number;
imageGeneratorTargetSize?: ImageGeneratorTargetSize;
hasClipboard: boolean;
contextMenuHandlers: NodeContextMenuHandlers | null;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
@@ -4044,11 +4069,13 @@ function CanvasNodeView({
const booleanShapeGroup = isBooleanShapeGroupNode(node);
const lineShapeNode = isLineShapeNode(node);
const penStroke = node.type === "image" && node.layerRole === "pen-stroke";
const imageGenerator = node.type === "image" && node.layerRole === "image-generator";
const textOverlay = node.type === "text" && node.layerRole === "editable-text";
const textOverlayStyle = textOverlay ? editableTextLayerStyle(node) : undefined;
const textEditorRef = useRef<HTMLTextAreaElement | null>(null);
const selectionTitle = manualFrame ? "Frame" : shapeNode || booleanShapeGroup ? node.title.trim() || "Shape" : penStroke ? node.title.trim() || "Pen stroke" : node.title.trim() || (node.type === "image" ? "Image" : node.type);
const selectionSize = `${Math.round(node.width)} × ${Math.round(node.height)}`;
const visibleSelectionSize = imageGenerator && imageGeneratorTargetSize ? imageGeneratorTargetSize : { width: node.width, height: node.height };
const selectionSize = `${Math.round(visibleSelectionSize.width)} × ${Math.round(visibleSelectionSize.height)}`;
const SelectionIcon = node.type === "frame" ? Frame : node.type === "text" ? Type : penStroke ? PenLine : ImageIcon;
useEffect(() => {
@@ -4080,7 +4107,7 @@ function CanvasNodeView({
{shapeNode || booleanShapeGroup ? (
<ShapeNodePreview node={node} />
) : visual ? (
<ArtboardPreview node={node} viewportScale={viewportScale} />
<ArtboardPreview node={node} viewportScale={viewportScale} imageGeneratorTargetSize={imageGeneratorTargetSize} />
) : textOverlay ? (
editing ? (
<textarea
@@ -4128,7 +4155,7 @@ function CanvasNodeView({
title={selectionTitle}
size={selectionSize}
Icon={SelectionIcon}
variant={manualFrame ? "frame" : penStroke ? "pen" : shapeNode || booleanShapeGroup ? "shape" : textOverlay ? "text" : undefined}
variant={manualFrame ? "frame" : imageGenerator ? "image-generator" : penStroke ? "pen" : shapeNode || booleanShapeGroup ? "shape" : textOverlay ? "text" : undefined}
selectionScale={selectionScale}
transform={transform}
onResizePointerDown={onResizePointerDown}
@@ -5404,6 +5431,56 @@ function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlyS
.sort((a, b) => b.updateTimestamp - a.updateTimestamp)[0]?.agentThreadId || "";
}
function preserveLocalDraftImageGenerators(nextProject: Project, currentNodes: CanvasNode[]) {
const nextNodeIds = new Set(nextProject.nodes.map((node) => node.id));
const localDraftGenerators = currentNodes.filter((node) => node.type === "image" && node.layerRole === "image-generator" && node.status === "draft" && !nextNodeIds.has(node.id));
if (localDraftGenerators.length === 0) return nextProject;
return {
...nextProject,
nodes: [...nextProject.nodes, ...localDraftGenerators]
};
}
function nextImageGeneratorPosition(origin: { x: number; y: number }, size: { width: number; height: number }, nodes: CanvasNode[], viewportScale: number) {
const base = {
x: origin.x - size.width / 2,
y: origin.y - size.height / 2
};
const generators = nodes.filter((node) => node.type === "image" && node.layerRole === "image-generator");
const gap = 24 / Math.max(viewportScale, 0.01);
const step = {
x: size.width + gap,
y: size.height + gap
};
const overlapsAnyGenerator = (candidate: { x: number; y: number }) =>
generators.some((node) => rectanglesOverlap({ ...candidate, ...size }, node));
if (!overlapsAnyGenerator(base)) return base;
for (let ring = 1; ring <= 6; ring += 1) {
for (let row = -ring; row <= ring; row += 1) {
for (let column = -ring; column <= ring; column += 1) {
if (Math.max(Math.abs(row), Math.abs(column)) !== ring) continue;
const candidate = {
x: base.x + column * step.x,
y: base.y + row * step.y
};
if (!overlapsAnyGenerator(candidate)) return candidate;
}
}
}
const fallbackOffset = generators.length * gap;
return {
x: base.x + fallbackOffset,
y: base.y + fallbackOffset
};
}
function rectanglesOverlap(a: { x: number; y: number; width: number; height: number }, b: CanvasNode) {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}
function agentThreadTitleText(content: string) {
return visiblePromptText(content);
}