diff --git a/frontend/src/ui/components/ArtboardPreview/index.css b/frontend/src/ui/components/ArtboardPreview/index.css index 607a259..9306234 100644 --- a/frontend/src/ui/components/ArtboardPreview/index.css +++ b/frontend/src/ui/components/ArtboardPreview/index.css @@ -104,43 +104,75 @@ } .image-generator-preview { + --image-generator-viewport-scale: 1; position: relative; width: 100%; height: 100%; overflow: visible; } -.image-generator-label { +.image-generator-meta { position: absolute; left: 0; - top: -28px; - display: inline-flex; - align-items: center; - gap: 6px; + right: 0; + top: calc(-30px / var(--image-generator-viewport-scale)); + z-index: 2; + height: calc(24px / var(--image-generator-viewport-scale)); color: #2f80ff; - font-size: 15px; + font-size: calc(16px / var(--image-generator-viewport-scale)); font-weight: 500; - line-height: 22px; + line-height: calc(24px / var(--image-generator-viewport-scale)); + letter-spacing: 0; + pointer-events: none; white-space: nowrap; } +.image-generator-label { + position: absolute; + left: 0; + top: 0; + display: inline-flex; + align-items: center; + gap: calc(6px / var(--image-generator-viewport-scale)); + max-width: calc(100% - (126px / var(--image-generator-viewport-scale))); + overflow: hidden; + text-overflow: ellipsis; +} + .image-generator-label svg { - width: 15px; - height: 15px; + width: calc(17px / var(--image-generator-viewport-scale)); + height: calc(17px / var(--image-generator-viewport-scale)); + flex: 0 0 auto; fill: #2f80ff; stroke: #2f80ff; } +.image-generator-label span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.image-generator-size-label { + position: absolute; + top: 0; + right: 0; + text-align: right; +} + .image-generator-frame { + box-sizing: border-box; width: 100%; height: 100%; display: grid; place-items: center; - border: 2px solid #2f80ff; + border: calc(2px / var(--image-generator-viewport-scale)) solid #2f80ff; background: #eeeeed; } .image-generator-frame svg { + width: calc(34px / var(--image-generator-viewport-scale)); + height: calc(34px / var(--image-generator-viewport-scale)); color: #cfd0d2; fill: #cfd0d2; stroke: #cfd0d2; diff --git a/frontend/src/ui/components/ArtboardPreview/index.tsx b/frontend/src/ui/components/ArtboardPreview/index.tsx index a40cb1d..f114b73 100644 --- a/frontend/src/ui/components/ArtboardPreview/index.tsx +++ b/frontend/src/ui/components/ArtboardPreview/index.tsx @@ -10,9 +10,10 @@ import { imageAdjustmentImageStyle, imageAdjustmentLayerStyle, isDefaultImageAdj import { canvasImageUrl } from "@/ui/lib/imageDelivery"; import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle"; +import { imageGeneratorTargetSizeFromNode, type ImageGeneratorTargetSize } from "@/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing"; import "./index.css"; -export function ArtboardPreview({ node, viewportScale = 1 }: { node: CanvasNode; viewportScale?: number }) { +export function ArtboardPreview({ node, viewportScale = 1, imageGeneratorTargetSize }: { node: CanvasNode; viewportScale?: number; imageGeneratorTargetSize?: ImageGeneratorTargetSize }) { const { t } = useI18n(); const overlayUiStyle = workOverlayUiStyle(viewportScale, node); @@ -57,7 +58,7 @@ export function ArtboardPreview({ node, viewportScale = 1 }: { node: CanvasNode; } if (node.type === "image" && node.layerRole === "image-generator") { - return ; + return ; } if (node.type === "frame" && node.layerRole === "manual-frame") { @@ -110,21 +111,31 @@ function clampNumber(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } +function imageGeneratorUiStyle(viewportScale: number) { + return { + "--image-generator-viewport-scale": String(Math.max(viewportScale, 0.01)) + } as CSSProperties; +} + function artboardKind(node: CanvasNode) { if (node.height > node.width * 1.24) return "tall"; if (node.width > node.height * 1.35) return "wide"; return "square"; } -function ImageGeneratorPreview({ node }: { node: CanvasNode }) { +function ImageGeneratorPreview({ node, viewportScale, targetSize }: { node: CanvasNode; viewportScale: number; targetSize?: ImageGeneratorTargetSize }) { + const size = targetSize ?? imageGeneratorTargetSizeFromNode(node); return ( -
-
- - {node.title} +
+
+
+
+
{size.width} × {size.height}
- +
); diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame/index.tsx index dafec87..0bf18fe 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/CanvasSelectionFrame/index.tsx @@ -27,7 +27,7 @@ export function CanvasSelectionFrame({ title: string; size: string; Icon: ComponentType<{ size?: number }>; - variant?: "frame" | "pen" | "text" | "shape" | "line-shape"; + variant?: "frame" | "image-generator" | "pen" | "text" | "shape" | "line-shape"; selectionScale: number; transform: NodeTransform; onResizePointerDown: (event: ReactPointerEvent, handle: ResizeHandle) => void; @@ -41,7 +41,10 @@ export function CanvasSelectionFrame({ } return ( -
+
diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing.ts index d832e47..154a003 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing.ts @@ -1,10 +1,18 @@ import type { ComposerImageSettings } from "@/ui/components/CanvasAgentComposer"; +import type { CanvasNode } from "@/domain/design"; type StageSize = { width: number; height: number; }; +export type ImageGeneratorTargetSize = { + width: number; + height: number; +}; + +const targetMetadataKey = "imageGeneratorTarget"; + export function imageGeneratorPlaceholderSize(settings: ComposerImageSettings, viewportScale: number, stage: StageSize) { const width = Math.max(1, settings.width); const height = Math.max(1, settings.height); @@ -26,6 +34,67 @@ export function imageGeneratorPlaceholderSize(settings: ComposerImageSettings, v }; } +export function imageGeneratorTargetSizeFromSettings(settings: ComposerImageSettings): ImageGeneratorTargetSize { + return { + width: positiveInteger(settings.width, 1024), + height: positiveInteger(settings.height, 1024) + }; +} + +export function imageGeneratorTargetSizeFromNode(node: CanvasNode): ImageGeneratorTargetSize { + const metadata = parseImageGeneratorTargetMetadata(node.imageAdjustments); + if (metadata) { + return { + width: positiveInteger(metadata.width, 1024), + height: positiveInteger(metadata.height, 1024) + }; + } + const ratio = Math.max(node.width, 1) / Math.max(node.height, 1); + if (ratio >= 1) { + return { + width: positiveInteger(1024 * ratio, 1024), + height: 1024 + }; + } + return { + width: 1024, + height: positiveInteger(1024 / ratio, 1024) + }; +} + +export function serializeImageGeneratorTargetSize(settings: ComposerImageSettings, previous?: string): string { + const metadata = parseJsonObject(previous); + metadata[targetMetadataKey] = imageGeneratorTargetSizeFromSettings(settings); + return JSON.stringify(metadata); +} + function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } + +function parseImageGeneratorTargetMetadata(value?: string | null): Partial | null { + const metadata = parseJsonObject(value)[targetMetadataKey]; + if (!metadata || typeof metadata !== "object") return null; + const source = metadata as Partial>; + return { + width: Number(source.width), + height: Number(source.height) + }; +} + +function parseJsonObject(value?: string | null): Record { + if (!value) return {}; + try { + const parsed = JSON.parse(value) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return { ...(parsed as Record) }; + } catch { + return {}; + } + return {}; +} + +function positiveInteger(value: unknown, fallback: number) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return Math.max(1, Math.round(fallback)); + return Math.max(1, Math.round(numeric)); +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index ab78c0e..0e721ae 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -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(() => { 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 ( handleNodePointerDown(event, node)} @@ -3475,7 +3498,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { - {selectedImageGeneratorBounds && ( + {selectedImageGeneratorBounds && selectedImageGeneratorNode?.status === "draft" && ( ) => 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(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 ? ( ) : visual ? ( - + ) : textOverlay ? ( editing ? (