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:
@@ -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;
|
||||
|
||||
@@ -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 <ImageGeneratorPreview node={node} />;
|
||||
return <ImageGeneratorPreview node={node} viewportScale={viewportScale} targetSize={imageGeneratorTargetSize} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="image-generator-preview">
|
||||
<div className="image-generator-label">
|
||||
<ImageIcon size={14} />
|
||||
<span>{node.title}</span>
|
||||
<div className="image-generator-preview" style={imageGeneratorUiStyle(viewportScale)}>
|
||||
<div className="image-generator-meta">
|
||||
<div className="image-generator-label">
|
||||
<ImageIcon aria-hidden="true" />
|
||||
<span>{node.title}</span>
|
||||
</div>
|
||||
<div className="image-generator-size-label">{size.width} × {size.height}</div>
|
||||
</div>
|
||||
<div className="image-generator-frame">
|
||||
<ImageIcon size={34} />
|
||||
<ImageIcon aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLButtonElement>, handle: ResizeHandle) => void;
|
||||
@@ -41,7 +41,10 @@ export function CanvasSelectionFrame({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`moteva-selection-frame ${variant === "frame" ? "frame-selection-frame" : ""} ${variant === "pen" ? "pen-stroke-selection-frame" : ""} ${variant === "shape" ? "shape-selection-frame" : ""}`} style={{ "--selection-scale": selectionScale } as CSSProperties}>
|
||||
<div
|
||||
className={`moteva-selection-frame ${variant === "frame" ? "frame-selection-frame" : ""} ${variant === "image-generator" ? "image-generator-selection-frame" : ""} ${variant === "pen" ? "pen-stroke-selection-frame" : ""} ${variant === "shape" ? "shape-selection-frame" : ""}`}
|
||||
style={{ "--selection-scale": selectionScale } as CSSProperties}
|
||||
>
|
||||
<div className="moteva-selection-meta">
|
||||
<span className="moteva-selection-title-wrap">
|
||||
<Icon size={17} />
|
||||
|
||||
@@ -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<ImageGeneratorTargetSize> | null {
|
||||
const metadata = parseJsonObject(value)[targetMetadataKey];
|
||||
if (!metadata || typeof metadata !== "object") return null;
|
||||
const source = metadata as Partial<Record<keyof ImageGeneratorTargetSize, unknown>>;
|
||||
return {
|
||||
width: Number(source.width),
|
||||
height: Number(source.height)
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(value?: string | null): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return { ...(parsed as Record<string, unknown>) };
|
||||
} 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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2365,6 +2365,14 @@ button:disabled {
|
||||
right: calc(10px / var(--selection-scale));
|
||||
}
|
||||
|
||||
.image-generator-selection-frame {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.image-generator-selection-frame .moteva-selection-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pen-stroke-selection-frame .moteva-selection-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user