Compare commits

...

3 Commits

Author SHA1 Message Date
root 94718553c6 feat(ui): redesign image generator floating composer and settings panel
Compact the floating composer with an auto-resizing prompt editor and a
scrollable settings popover, add a titled settings panel, and show the
resolved image size in the settings summary. Add the imageCountLabel
string for the count section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:46:24 +08:00
root cbf4e21e2b 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>
2026-07-09 01:46:16 +08:00
root 82683c40ea refactor(canvas): support multiple concurrent image generator streams
Track image-generator streams in a keyed Map/Set instead of a single ref
so several placeholders can generate at once without cancelling each
other. Generating state is derived from the number of open streams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:46:07 +08:00
13 changed files with 523 additions and 107 deletions
+1
View File
@@ -242,6 +242,7 @@
"referenceImage": "Reference", "referenceImage": "Reference",
"imageSettings": "Image settings", "imageSettings": "Image settings",
"imageCountUnit": "{count} img", "imageCountUnit": "{count} img",
"imageCountLabel": "Image",
"quality": "Quality", "quality": "Quality",
"qualityAuto": "Auto", "qualityAuto": "Auto",
"qualityHigh": "High", "qualityHigh": "High",
+1
View File
@@ -242,6 +242,7 @@
"referenceImage": "参考图", "referenceImage": "参考图",
"imageSettings": "图片参数", "imageSettings": "图片参数",
"imageCountUnit": "{count} 张", "imageCountUnit": "{count} 张",
"imageCountLabel": "生成数量",
"quality": "质量", "quality": "质量",
"qualityAuto": "自动", "qualityAuto": "自动",
"qualityHigh": "高", "qualityHigh": "高",
@@ -104,43 +104,75 @@
} }
.image-generator-preview { .image-generator-preview {
--image-generator-viewport-scale: 1;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: visible; overflow: visible;
} }
.image-generator-label { .image-generator-meta {
position: absolute; position: absolute;
left: 0; left: 0;
top: -28px; right: 0;
display: inline-flex; top: calc(-30px / var(--image-generator-viewport-scale));
align-items: center; z-index: 2;
gap: 6px; height: calc(24px / var(--image-generator-viewport-scale));
color: #2f80ff; color: #2f80ff;
font-size: 15px; font-size: calc(16px / var(--image-generator-viewport-scale));
font-weight: 500; font-weight: 500;
line-height: 22px; line-height: calc(24px / var(--image-generator-viewport-scale));
letter-spacing: 0;
pointer-events: none;
white-space: nowrap; 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 { .image-generator-label svg {
width: 15px; width: calc(17px / var(--image-generator-viewport-scale));
height: 15px; height: calc(17px / var(--image-generator-viewport-scale));
flex: 0 0 auto;
fill: #2f80ff; fill: #2f80ff;
stroke: #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 { .image-generator-frame {
box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
border: 2px solid #2f80ff; border: calc(2px / var(--image-generator-viewport-scale)) solid #2f80ff;
background: #eeeeed; background: #eeeeed;
} }
.image-generator-frame svg { .image-generator-frame svg {
width: calc(34px / var(--image-generator-viewport-scale));
height: calc(34px / var(--image-generator-viewport-scale));
color: #cfd0d2; color: #cfd0d2;
fill: #cfd0d2; fill: #cfd0d2;
stroke: #cfd0d2; stroke: #cfd0d2;
@@ -10,9 +10,10 @@ import { imageAdjustmentImageStyle, imageAdjustmentLayerStyle, isDefaultImageAdj
import { canvasImageUrl } from "@/ui/lib/imageDelivery"; import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps"; import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
import { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle"; import { framePreviewStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
import { imageGeneratorTargetSizeFromNode, type ImageGeneratorTargetSize } from "@/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing";
import "./index.css"; 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 { t } = useI18n();
const overlayUiStyle = workOverlayUiStyle(viewportScale, node); 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") { 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") { 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); 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) { function artboardKind(node: CanvasNode) {
if (node.height > node.width * 1.24) return "tall"; if (node.height > node.width * 1.24) return "tall";
if (node.width > node.height * 1.35) return "wide"; if (node.width > node.height * 1.35) return "wide";
return "square"; return "square";
} }
function ImageGeneratorPreview({ node }: { node: CanvasNode }) { function ImageGeneratorPreview({ node, viewportScale, targetSize }: { node: CanvasNode; viewportScale: number; targetSize?: ImageGeneratorTargetSize }) {
const size = targetSize ?? imageGeneratorTargetSizeFromNode(node);
return ( return (
<div className="image-generator-preview"> <div className="image-generator-preview" style={imageGeneratorUiStyle(viewportScale)}>
<div className="image-generator-meta">
<div className="image-generator-label"> <div className="image-generator-label">
<ImageIcon size={14} /> <ImageIcon aria-hidden="true" />
<span>{node.title}</span> <span>{node.title}</span>
</div> </div>
<div className="image-generator-size-label">{size.width} × {size.height}</div>
</div>
<div className="image-generator-frame"> <div className="image-generator-frame">
<ImageIcon size={34} /> <ImageIcon aria-hidden="true" />
</div> </div>
</div> </div>
); );
@@ -413,6 +413,14 @@
gap: 20px; gap: 20px;
} }
.composer-settings-panel h3 {
margin: 0;
color: #242a34;
font-size: 15px;
font-weight: 700;
line-height: 22px;
}
.composer-settings-panel section { .composer-settings-panel section {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -107,7 +107,8 @@ export function composerImageSize(settings: ComposerImageSettings) {
export function composerSettingsSummary(settings: ComposerImageSettings) { export function composerSettingsSummary(settings: ComposerImageSettings) {
const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio; const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio;
return `${qualityLabel(settings.quality)} · ${ratioLabel} · ${settings.count} img`; const sizeLabel = settings.ratio === "auto" ? composerImageSize(settings).replace("x", "×") : ratioLabel;
return `${qualityLabel(settings.quality)} · ${sizeLabel} · ${settings.count} img`;
} }
function qualityLabel(quality: ComposerImageQuality) { function qualityLabel(quality: ComposerImageQuality) {
@@ -348,16 +349,19 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
export function ComposerSettingsPanel({ export function ComposerSettingsPanel({
settings, settings,
onPatch, onPatch,
onSelectRatio onSelectRatio,
title
}: { }: {
settings: ComposerImageSettings; settings: ComposerImageSettings;
onPatch: (patch: Partial<ComposerImageSettings>) => void; onPatch: (patch: Partial<ComposerImageSettings>) => void;
onSelectRatio: (ratio: ComposerImageRatio) => void; onSelectRatio: (ratio: ComposerImageRatio) => void;
title?: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="composer-settings-panel"> <div className="composer-settings-panel">
{title && <h3>{title}</h3>}
<section> <section>
<h4>{t("quality")}</h4> <h4>{t("quality")}</h4>
<div className="composer-quality-control"> <div className="composer-quality-control">
@@ -391,7 +395,7 @@ export function ComposerSettingsPanel({
</div> </div>
</section> </section>
<section> <section>
<h4>Image</h4> <h4>{t("imageCountLabel")}</h4>
<div className="composer-count-grid"> <div className="composer-count-grid">
{Array.from({ length: 10 }, (_, index) => index + 1).map((count) => ( {Array.from({ length: 10 }, (_, index) => index + 1).map((count) => (
<button key={count} type="button" className={settings.count === count ? "selected" : ""} onClick={() => onPatch({ count })}> <button key={count} type="button" className={settings.count === count ? "selected" : ""} onClick={() => onPatch({ count })}>
@@ -1,36 +1,47 @@
.image-generator-floating-composer { .image-generator-floating-composer {
position: absolute; position: absolute;
z-index: 36; z-index: 36;
min-height: 188px; min-height: 150px;
border: 1px solid #cbd2dc; overflow: hidden;
border-radius: 28px; border: 0.5px solid #d7d9de;
border-radius: 24px;
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.14); box-shadow: 0 18px 45px rgba(17, 24, 39, 0.13);
backdrop-filter: blur(18px);
pointer-events: auto; pointer-events: auto;
} }
.image-generator-reference-row { .image-generator-reference-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; padding: 6px;
min-height: 72px; gap: 8px;
margin: 14px 16px 0; min-height: 68px;
margin: 8px 8px 0;
overflow-x: auto;
scrollbar-width: none;
}
.image-generator-reference-row::-webkit-scrollbar {
display: none;
} }
.image-generator-reference-tile, .image-generator-reference-tile,
.image-generator-reference-preview { .image-generator-reference-preview {
width: 72px; width: 56px;
height: 72px; height: 68px;
border-radius: 20px; border-radius: 16px;
} }
.image-generator-reference-tile { .image-generator-reference-tile {
display: grid; display: grid;
place-items: center; place-items: center;
gap: 6px; gap: 5px;
color: #a6abb5; flex: 0 0 auto;
color: rgba(20, 20, 20, 0.46);
background: #f4f4f4; background: #f4f4f4;
font-size: 12px; font-size: 11px;
line-height: 1.2;
} }
.image-generator-reference-tile span { .image-generator-reference-tile span {
@@ -48,7 +59,7 @@
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 20px; border-radius: 16px;
object-fit: cover; object-fit: cover;
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
@@ -77,23 +88,26 @@
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: 92px; height: auto;
margin: 10px 0 0; min-height: 44px;
padding: 0 32px; max-height: 176px;
margin: 2px 0 0;
padding: 1px 16px 0;
border: 0; border: 0;
outline: 0; outline: 0;
resize: none; resize: none;
color: #1f2937; overflow-y: hidden;
color: rgba(0, 0, 0, 0.9);
background: transparent; background: transparent;
caret-color: #111827; caret-color: #111827;
font-family: inherit; font-family: inherit;
font-size: 18px; font-size: 14px;
font-weight: 520; font-weight: 500;
line-height: 26px; line-height: 25px;
} }
.image-generator-prompt-editor::placeholder { .image-generator-prompt-editor::placeholder {
color: #a6acb6; color: rgba(0, 0, 0, 0.3);
opacity: 1; opacity: 1;
} }
@@ -111,8 +125,8 @@
.image-generator-composer-toolbar { .image-generator-composer-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 4px;
padding: 8px 14px 13px 28px; padding: 4px 8px 8px;
} }
.image-generator-settings-chip, .image-generator-settings-chip,
@@ -121,17 +135,21 @@
height: 36px; height: 36px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 999px; border-radius: 999px;
color: #222831; color: #222831;
background: #fff; background: #fff;
font-size: 15px; font-size: 13px;
font-weight: 560; font-weight: 500;
} }
.image-generator-settings-chip { .image-generator-settings-chip {
padding: 0 8px; height: 32px;
max-width: 220px;
padding: 0 8px 0 10px;
overflow: hidden;
white-space: nowrap;
} }
.image-generator-settings-chip:hover, .image-generator-settings-chip:hover,
@@ -150,11 +168,13 @@
.image-generator-composer-spacer { .image-generator-composer-spacer {
flex: 1; flex: 1;
min-width: 6px;
} }
.image-generator-model-button { .image-generator-model-button {
max-width: 162px; height: 32px;
padding: 0 10px; max-width: 140px;
padding: 0 6px 0 8px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
@@ -166,9 +186,10 @@
} }
.image-generator-send-button { .image-generator-send-button {
min-width: 58px; height: 32px;
min-width: 56px;
justify-content: center; justify-content: center;
padding: 0 18px; padding: 0 12px;
color: #fff; color: #fff;
background: #222; background: #222;
font-weight: 760; font-weight: 760;
@@ -179,3 +200,144 @@
background: #efefef; background: #efefef;
opacity: 1; opacity: 1;
} }
.composer-settings-popover.image-generator-settings-popover-panel {
width: min(312px, calc(100vw - 24px));
max-height: min(480px, calc(100vh - 40px));
overflow: auto;
padding: 0;
border: 0.5px solid #d9dce2;
border-radius: 16px;
box-shadow: 0 14px 36px rgba(17, 24, 39, 0.14);
scrollbar-width: thin;
scrollbar-color: rgba(16, 24, 40, 0.28) transparent;
}
.image-generator-settings-popover-panel::-webkit-scrollbar {
width: 10px;
}
.image-generator-settings-popover-panel::-webkit-scrollbar-track {
background: transparent;
}
.image-generator-settings-popover-panel::-webkit-scrollbar-thumb {
min-height: 42px;
border: 3px solid transparent;
border-radius: 999px;
background: rgba(16, 24, 40, 0.28);
background-clip: content-box;
}
.image-generator-settings-popover-panel::-webkit-scrollbar-thumb:hover {
background: rgba(16, 24, 40, 0.38);
background-clip: content-box;
}
.image-generator-settings-popover-panel .composer-settings-panel {
gap: 16px;
padding: 14px 16px 16px;
}
.image-generator-settings-popover-panel .composer-settings-panel h3 {
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.image-generator-settings-popover-panel .composer-settings-panel section {
gap: 8px;
}
.image-generator-settings-popover-panel .composer-settings-panel h4 {
font-size: 13px;
font-weight: 600;
line-height: 20px;
}
.image-generator-settings-popover-panel .composer-quality-control {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
overflow: visible;
border-radius: 0;
background: transparent;
}
.image-generator-settings-popover-panel .composer-quality-control button,
.image-generator-settings-popover-panel .composer-count-grid button {
height: 32px;
border: 1px solid #d8dbe1;
border-radius: 999px;
background: #fff;
color: #262b34;
font-size: 13px;
line-height: 20px;
}
.image-generator-settings-popover-panel .composer-quality-control button.selected {
border-color: #2d3036;
background: #fff;
}
.image-generator-settings-popover-panel .composer-dimensions {
gap: 8px;
}
.image-generator-settings-popover-panel .composer-dimensions label {
height: 32px;
gap: 6px;
padding: 0 8px;
border-radius: 8px;
background: #f3f3f3;
}
.image-generator-settings-popover-panel .composer-dimensions input {
font-size: 13px;
line-height: 20px;
}
.image-generator-settings-popover-panel .composer-dimensions i {
width: 32px;
color: #7c828c;
text-align: center;
}
.image-generator-settings-popover-panel .composer-ratio-grid,
.image-generator-settings-popover-panel .composer-count-grid {
gap: 8px;
}
.image-generator-settings-popover-panel .composer-ratio-grid button {
aspect-ratio: auto;
height: 72px;
gap: 6px;
padding: 8px;
border-radius: 12px;
color: #262b34;
font-size: 12px;
line-height: 1;
}
.image-generator-settings-popover-panel .composer-ratio-grid button.selected,
.image-generator-settings-popover-panel .composer-count-grid button.selected {
border-color: #d3d6dd;
background: #eeeeee;
}
.image-generator-settings-popover-panel .ratio-glyph {
width: 20px;
height: 20px;
border-width: 2px;
border-color: #525862;
border-radius: 4px;
}
.image-generator-settings-popover-panel .ratio-glyph.wide {
width: 24px;
height: 16px;
}
.image-generator-settings-popover-panel .ratio-glyph.portrait {
width: 16px;
height: 24px;
}
@@ -9,6 +9,7 @@ import type { AgentContent } from "@/domain/design";
import { useI18n } from "@/i18n/i18n"; import { useI18n } from "@/i18n/i18n";
import { import {
ComposerSettingsPanel, ComposerSettingsPanel,
composerImageSize,
composerRatioOptions, composerRatioOptions,
type CanvasComposerModel, type CanvasComposerModel,
type ComposerImageRatio, type ComposerImageRatio,
@@ -34,12 +35,12 @@ type ImageGeneratorFloatingComposerProps = {
onSubmit: (contents: AgentContent[], reset: () => void) => void; onSubmit: (contents: AgentContent[], reset: () => void) => void;
}; };
const horizontalInset = 36; const horizontalInset = 24;
const preferredWidth = 860; const preferredWidth = 536;
const minimumWidth = 520; const minimumWidth = 320;
const verticalInset = 18; const verticalInset = 18;
const estimatedComposerHeight = 210; const estimatedComposerHeight = 150;
const composerGap = 18; const composerGap = 16;
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
@@ -85,8 +86,9 @@ function composerStyle(bounds: NodeScreenBounds, stageWidth: number, stageHeight
function imageSettingsSummary(settings: ComposerImageSettings, t: (key: string) => string) { function imageSettingsSummary(settings: ComposerImageSettings, t: (key: string) => string) {
const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio; const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio;
const sizeLabel = settings.ratio === "auto" ? composerImageSize(settings).replace("x", "×") : ratioLabel;
const countLabel = t("imageCountUnit").replace("{count}", String(settings.count)); const countLabel = t("imageCountUnit").replace("{count}", String(settings.count));
return `${t(`quality${settings.quality[0].toUpperCase()}${settings.quality.slice(1)}`)} · ${ratioLabel} · ${countLabel}`; return `${t(`quality${settings.quality[0].toUpperCase()}${settings.quality.slice(1)}`)} · ${sizeLabel} · ${countLabel}`;
} }
export function ImageGeneratorFloatingComposer({ export function ImageGeneratorFloatingComposer({
@@ -106,6 +108,7 @@ export function ImageGeneratorFloatingComposer({
}: ImageGeneratorFloatingComposerProps) { }: ImageGeneratorFloatingComposerProps) {
const { t } = useI18n(); const { t } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const promptEditorRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [references, setReferences] = useState<UploadedReferenceImage[]>([]); const [references, setReferences] = useState<UploadedReferenceImage[]>([]);
@@ -126,6 +129,18 @@ export function ImageGeneratorFloatingComposer({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
useLayoutEffect(() => {
const editor = promptEditorRef.current;
if (!editor) return;
const style = window.getComputedStyle(editor);
const minHeight = Number.parseFloat(style.minHeight) || 44;
const maxHeight = Number.parseFloat(style.maxHeight) || 176;
editor.style.height = "auto";
const nextHeight = Math.min(Math.max(editor.scrollHeight, minHeight), maxHeight);
editor.style.height = `${nextHeight}px`;
editor.style.overflowY = editor.scrollHeight > maxHeight ? "auto" : "hidden";
}, [promptValue]);
const patchSettings = (patch: Partial<ComposerImageSettings>) => { const patchSettings = (patch: Partial<ComposerImageSettings>) => {
onSettingsChange({ ...settings, ...patch }); onSettingsChange({ ...settings, ...patch });
}; };
@@ -209,6 +224,7 @@ export function ImageGeneratorFloatingComposer({
</button> </button>
</div> </div>
<textarea <textarea
ref={promptEditorRef}
value={promptValue} value={promptValue}
placeholder={t("canvasPromptPlaceholder")} placeholder={t("canvasPromptPlaceholder")}
aria-label={t("canvasPromptPlaceholder")} aria-label={t("canvasPromptPlaceholder")}
@@ -225,8 +241,8 @@ export function ImageGeneratorFloatingComposer({
{settingsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />} {settingsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="composer-settings-popover" align="start" sideOffset={12}> <PopoverContent className="composer-settings-popover image-generator-settings-popover-panel" align="start" sideOffset={12}>
<ComposerSettingsPanel settings={settings} onPatch={patchSettings} onSelectRatio={selectRatio} /> <ComposerSettingsPanel settings={settings} onPatch={patchSettings} onSelectRatio={selectRatio} title={t("imageSettings")} />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -27,7 +27,7 @@ export function CanvasSelectionFrame({
title: string; title: string;
size: string; size: string;
Icon: ComponentType<{ size?: number }>; Icon: ComponentType<{ size?: number }>;
variant?: "frame" | "pen" | "text" | "shape" | "line-shape"; variant?: "frame" | "image-generator" | "pen" | "text" | "shape" | "line-shape";
selectionScale: number; selectionScale: number;
transform: NodeTransform; transform: NodeTransform;
onResizePointerDown: (event: ReactPointerEvent<HTMLButtonElement>, handle: ResizeHandle) => void; onResizePointerDown: (event: ReactPointerEvent<HTMLButtonElement>, handle: ResizeHandle) => void;
@@ -41,7 +41,10 @@ export function CanvasSelectionFrame({
} }
return ( 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"> <div className="moteva-selection-meta">
<span className="moteva-selection-title-wrap"> <span className="moteva-selection-title-wrap">
<Icon size={17} /> <Icon size={17} />
@@ -34,10 +34,10 @@ export function useGenerationStream({
const toUserMessageText = useUserMessage(); const toUserMessageText = useUserMessage();
const closeAgentEventsRef = useRef<(() => void) | null>(null); const closeAgentEventsRef = useRef<(() => void) | null>(null);
const closeAgentProjectPollRef = useRef<(() => void) | null>(null); const closeAgentProjectPollRef = useRef<(() => void) | null>(null);
const closeImageGeneratorEventsRef = useRef<(() => void) | null>(null); const closeImageGeneratorEventsRef = useRef<Map<string, () => void>>(new Map());
const closeCanvasActionEventsRef = useRef<Map<string, () => void>>(new Map()); const closeCanvasActionEventsRef = useRef<Map<string, () => void>>(new Map());
const agentStreamingProjectIdRef = useRef<string | null>(null); const agentStreamingProjectIdRef = useRef<string | null>(null);
const imageGeneratorStreamingProjectIdRef = useRef<string | null>(null); const imageGeneratorStreamingProjectIdRef = useRef<Set<string>>(new Set());
const canvasActionStreamingProjectIdRef = useRef<Set<string>>(new Set()); const canvasActionStreamingProjectIdRef = useRef<Set<string>>(new Set());
const completeCanvasActionStream = useCallback( const completeCanvasActionStream = useCallback(
@@ -49,10 +49,19 @@ export function useGenerationStream({
[setCanvasActionGenerating] [setCanvasActionGenerating]
); );
const completeImageGeneratorStream = useCallback(
(streamKey: string) => {
closeImageGeneratorEventsRef.current.delete(streamKey);
imageGeneratorStreamingProjectIdRef.current.delete(streamKey);
setImageGeneratorGenerating(closeImageGeneratorEventsRef.current.size > 0);
},
[setImageGeneratorGenerating]
);
const completeChannel = useCallback( const completeChannel = useCallback(
(channel: GenerationChannel) => { (channel: GenerationChannel) => {
if (channel === "image-generator") { if (channel === "image-generator") {
setImageGeneratorGenerating(false); setImageGeneratorGenerating(closeImageGeneratorEventsRef.current.size > 0);
return; return;
} }
setAgentGenerating(false); setAgentGenerating(false);
@@ -66,12 +75,12 @@ export function useGenerationStream({
if (!threadId) return; if (!threadId) return;
if (channel === "canvas-action") { if (channel === "canvas-action") {
if (closeCanvasActionEventsRef.current.has(streamKey)) return; closeCanvasActionEventsRef.current.get(streamKey)?.();
setCanvasActionGenerating(true); setCanvasActionGenerating(true);
canvasActionStreamingProjectIdRef.current.add(streamKey); canvasActionStreamingProjectIdRef.current.add(streamKey);
} else { } else {
closeImageGeneratorEventsRef.current?.(); closeImageGeneratorEventsRef.current.get(streamKey)?.();
imageGeneratorStreamingProjectIdRef.current = streamKey; imageGeneratorStreamingProjectIdRef.current.add(streamKey);
setImageGeneratorGenerating(true); setImageGeneratorGenerating(true);
} }
@@ -90,9 +99,7 @@ export function useGenerationStream({
completeCanvasActionStream(streamKey); completeCanvasActionStream(streamKey);
return; return;
} }
completeChannel(channel); completeImageGeneratorStream(streamKey);
imageGeneratorStreamingProjectIdRef.current = null;
closeImageGeneratorEventsRef.current = null;
}; };
const poll = () => { const poll = () => {
void (async () => { void (async () => {
@@ -128,11 +135,11 @@ export function useGenerationStream({
if (channel === "canvas-action") { if (channel === "canvas-action") {
closeCanvasActionEventsRef.current.set(streamKey, close); closeCanvasActionEventsRef.current.set(streamKey, close);
} else { } else {
closeImageGeneratorEventsRef.current = close; closeImageGeneratorEventsRef.current.set(streamKey, close);
} }
poll(); poll();
}, },
[applyProject, completeCanvasActionStream, completeChannel, toUserMessageText, messages.generateError, setCanvasActionGenerating, setError, setImageGeneratorGenerating] [applyProject, completeCanvasActionStream, completeImageGeneratorStream, toUserMessageText, messages.generateError, setCanvasActionGenerating, setError, setImageGeneratorGenerating]
); );
const startAgentProjectPolling = useCallback( const startAgentProjectPolling = useCallback(
@@ -253,14 +260,24 @@ export function useGenerationStream({
(nextProjectId: string, payload: AgentChatPayload, channel: GenerationChannel = "agent") => { (nextProjectId: string, payload: AgentChatPayload, channel: GenerationChannel = "agent") => {
const streamKey = generationStreamKey(nextProjectId, payload.threadId); const streamKey = generationStreamKey(nextProjectId, payload.threadId);
if (channel !== "agent") { if (channel !== "agent") {
const closeRef = generationCloseRef(channel, closeAgentEventsRef, closeImageGeneratorEventsRef, closeCanvasActionEventsRef); if (channel === "canvas-action") {
const streamingRef = generationStreamingRef(channel, agentStreamingProjectIdRef, imageGeneratorStreamingProjectIdRef, canvasActionStreamingProjectIdRef); closeCanvasActionEventsRef.current.get(streamKey)?.();
closeRef.current?.(); setCanvasActionGenerating(true);
canvasActionStreamingProjectIdRef.current.add(streamKey);
} else {
closeImageGeneratorEventsRef.current.get(streamKey)?.();
setImageGeneratorGenerating(true);
imageGeneratorStreamingProjectIdRef.current.add(streamKey);
}
let closed = false; let closed = false;
closeRef.current = () => { const close = () => {
closed = true; closed = true;
}; };
streamingRef.current = streamKey; if (channel === "canvas-action") {
closeCanvasActionEventsRef.current.set(streamKey, close);
} else {
closeImageGeneratorEventsRef.current.set(streamKey, close);
}
void designGateway void designGateway
.agentChat(nextProjectId, payload) .agentChat(nextProjectId, payload)
.then((response) => { .then((response) => {
@@ -270,10 +287,12 @@ export function useGenerationStream({
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (closed) return; if (closed) return;
completeChannel(channel); if (channel === "canvas-action") {
completeCanvasActionStream(streamKey);
} else {
completeImageGeneratorStream(streamKey);
}
setError(toUserMessageText(error, "generateError")); setError(toUserMessageText(error, "generateError"));
streamingRef.current = null;
closeRef.current = null;
}); });
return; return;
} }
@@ -319,7 +338,7 @@ export function useGenerationStream({
} }
}); });
}, },
[appendThinkingMessage, applyProject, completeChannel, toUserMessageText, messages.generateError, messages.generateTimeout, setError, startAgentProjectPolling, startGeneratorTaskPolling] [appendThinkingMessage, applyProject, completeCanvasActionStream, completeChannel, completeImageGeneratorStream, toUserMessageText, messages.generateError, messages.generateTimeout, setCanvasActionGenerating, setError, setImageGeneratorGenerating, startAgentProjectPolling, startGeneratorTaskPolling]
); );
const stopGenerationStream = useCallback( const stopGenerationStream = useCallback(
@@ -331,6 +350,13 @@ export function useGenerationStream({
setCanvasActionGenerating(false); setCanvasActionGenerating(false);
return; return;
} }
if (channel === "image-generator") {
closeImageGeneratorEventsRef.current.forEach((close) => close());
closeImageGeneratorEventsRef.current.clear();
imageGeneratorStreamingProjectIdRef.current.clear();
setImageGeneratorGenerating(false);
return;
}
const closeRef = generationCloseRef(channel, closeAgentEventsRef, closeImageGeneratorEventsRef, closeCanvasActionEventsRef); const closeRef = generationCloseRef(channel, closeAgentEventsRef, closeImageGeneratorEventsRef, closeCanvasActionEventsRef);
const streamingRef = generationStreamingRef(channel, agentStreamingProjectIdRef, imageGeneratorStreamingProjectIdRef, canvasActionStreamingProjectIdRef); const streamingRef = generationStreamingRef(channel, agentStreamingProjectIdRef, imageGeneratorStreamingProjectIdRef, canvasActionStreamingProjectIdRef);
closeRef.current?.(); closeRef.current?.();
@@ -342,21 +368,21 @@ export function useGenerationStream({
streamingRef.current = null; streamingRef.current = null;
completeChannel(channel); completeChannel(channel);
}, },
[completeChannel, setCanvasActionGenerating] [completeChannel, setCanvasActionGenerating, setImageGeneratorGenerating]
); );
useEffect(() => { useEffect(() => {
return () => { return () => {
closeAgentEventsRef.current?.(); closeAgentEventsRef.current?.();
closeAgentProjectPollRef.current?.(); closeAgentProjectPollRef.current?.();
closeImageGeneratorEventsRef.current?.(); closeImageGeneratorEventsRef.current.forEach((close) => close());
closeCanvasActionEventsRef.current.forEach((close) => close()); closeCanvasActionEventsRef.current.forEach((close) => close());
closeAgentEventsRef.current = null; closeAgentEventsRef.current = null;
closeAgentProjectPollRef.current = null; closeAgentProjectPollRef.current = null;
closeImageGeneratorEventsRef.current = null; closeImageGeneratorEventsRef.current.clear();
closeCanvasActionEventsRef.current.clear(); closeCanvasActionEventsRef.current.clear();
agentStreamingProjectIdRef.current = null; agentStreamingProjectIdRef.current = null;
imageGeneratorStreamingProjectIdRef.current = null; imageGeneratorStreamingProjectIdRef.current.clear();
canvasActionStreamingProjectIdRef.current.clear(); canvasActionStreamingProjectIdRef.current.clear();
}; };
}, []); }, []);
@@ -375,20 +401,18 @@ export function useGenerationStream({
function generationCloseRef( function generationCloseRef(
channel: GenerationChannel, channel: GenerationChannel,
agentRef: RefObject<(() => void) | null>, agentRef: RefObject<(() => void) | null>,
imageGeneratorRef: RefObject<(() => void) | null>, _imageGeneratorRef: RefObject<Map<string, () => void>>,
_canvasActionRef: RefObject<Map<string, () => void>> _canvasActionRef: RefObject<Map<string, () => void>>
) { ) {
if (channel === "image-generator") return imageGeneratorRef;
return agentRef; return agentRef;
} }
function generationStreamingRef( function generationStreamingRef(
channel: GenerationChannel, channel: GenerationChannel,
agentRef: RefObject<string | null>, agentRef: RefObject<string | null>,
imageGeneratorRef: RefObject<string | null>, _imageGeneratorRef: RefObject<Set<string>>,
_canvasActionRef: RefObject<Set<string>> _canvasActionRef: RefObject<Set<string>>
) { ) {
if (channel === "image-generator") return imageGeneratorRef;
return agentRef; return agentRef;
} }
@@ -1,10 +1,18 @@
import type { ComposerImageSettings } from "@/ui/components/CanvasAgentComposer"; import type { ComposerImageSettings } from "@/ui/components/CanvasAgentComposer";
import type { CanvasNode } from "@/domain/design";
type StageSize = { type StageSize = {
width: number; width: number;
height: number; height: number;
}; };
export type ImageGeneratorTargetSize = {
width: number;
height: number;
};
const targetMetadataKey = "imageGeneratorTarget";
export function imageGeneratorPlaceholderSize(settings: ComposerImageSettings, viewportScale: number, stage: StageSize) { export function imageGeneratorPlaceholderSize(settings: ComposerImageSettings, viewportScale: number, stage: StageSize) {
const width = Math.max(1, settings.width); const width = Math.max(1, settings.width);
const height = Math.max(1, settings.height); 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) { function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max); 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));
}
+93 -16
View File
@@ -46,7 +46,7 @@ import { useCanvasDocument } from "./canvas/hooks/useCanvasDocument";
import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence"; import { useCanvasPersistence } from "./canvas/hooks/useCanvasPersistence";
import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection"; import { useCanvasSelection } from "./canvas/hooks/useCanvasSelection";
import { useGenerationStream, type GenerationChannel } from "./canvas/hooks/useGenerationStream"; 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 { canvasNodeToReferenceImage, isAssetReferencedByCanvas, isCanvasNodeReferenceImage } from "./canvas/nodeReferenceImages";
import { canvasNodeSvg, exportCanvasNode, exportImageNode, exportTextNode, imageNodeRasterBlob, svgToRasterBlob } from "./canvas/nodeExport"; 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"; 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]); }, [editingTextNodeId, nodes]);
const applyProject = useCallback((nextProject: Project, options: { preserveMessages?: boolean } = {}) => { const applyProject = useCallback((nextProject: Project, options: { preserveMessages?: boolean } = {}) => {
applyProjectDocument(nextProject); const mergedProject = preserveLocalDraftImageGenerators(nextProject, nodesRef.current);
applyProjectDocument(mergedProject);
if (!options.preserveMessages) { if (!options.preserveMessages) {
setMessages(visibleAgentMessagesForThread(nextProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current)); setMessages(visibleAgentMessagesForThread(mergedProject, imageGeneratorThreadIdsRef.current, selectedAgentThreadIdRef.current));
} }
setEditingTextNodeId(null); setEditingTextNodeId(null);
}, [applyProjectDocument]); }, [applyProjectDocument, nodesRef]);
const appendThinkingMessage = useCallback((message: AgentMessage) => { const appendThinkingMessage = useCallback((message: AgentMessage) => {
if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return; if (isImageGeneratorThreadMessage(message, imageGeneratorThreadIdsRef.current)) return;
@@ -937,7 +938,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null; const selectedImageNode = selectedControlNode && isRealCanvasImageNode(selectedControlNode) ? selectedControlNode : null;
const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false; const selectedImageNodeBusy = selectedImageNode ? isImageActionBusy(selectedImageNode) : false;
const selectedImageGeneratorNode = selectedControlNode?.type === "image" && selectedControlNode.layerRole === "image-generator" ? selectedControlNode : null; 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 selectedPenStrokeNode = selectedControlNode && isCanvasPenStrokeNode(selectedControlNode) ? selectedControlNode : null;
const selectedFrameNode = selectedControlNode?.type === "frame" && selectedControlNode.layerRole === "manual-frame" ? selectedControlNode : null; const selectedFrameNode = selectedControlNode?.type === "frame" && selectedControlNode.layerRole === "manual-frame" ? selectedControlNode : null;
const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null; const selectedShapeNode = selectedControlNode && isShapeNode(selectedControlNode) ? selectedControlNode : null;
@@ -962,6 +963,16 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
height: selectedImageGeneratorNode.height * viewport.k height: selectedImageGeneratorNode.height * viewport.k
}; };
}, [selectedImageGeneratorNode, viewport]); }, [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>(() => { const selectedFrameBounds = useMemo<NodeScreenBounds | null>(() => {
if (!selectedFrameNode) return null; if (!selectedFrameNode) return null;
return { return {
@@ -2105,6 +2116,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const deleteSelectedNodes = () => { const deleteSelectedNodes = () => {
const ids = new Set(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id)); const ids = new Set(selectedNodes.filter((node) => !lockedNodeIds.has(node.id) && !hiddenNodeIds.has(node.id)).map((node) => node.id));
if (ids.size === 0) return; if (ids.size === 0) return;
nodesRef.current = removeNodesById(nodesRef.current, ids);
setNodes((current) => removeNodesById(current, ids)); setNodes((current) => removeNodesById(current, ids));
setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId)))); setHiddenNodeIds((current) => new Set(Array.from(current).filter((nodeId) => !ids.has(nodeId))));
setLockedNodeIds((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 primary = isPrimaryShortcut(event);
const selected = selectedNode; const selected = selectedNode;
const selectedHandlers = selected && !lockedNodeIds.has(selected.id) && canUseNodeContextMenu(selected) ? createNodeContextMenuHandlers(selected) : null; 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) => { const run = (action: (() => void) | undefined, prevent = true) => {
if (!action) return false; if (!action) return false;
if (prevent) event.preventDefault(); 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 === "h") return void run(selectedHandlers?.toggleHidden);
if (primary && event.shiftKey && key === "l") return void run(selectedHandlers?.toggleLocked); 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") && 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); window.addEventListener("keydown", handleKeyDown);
@@ -2335,18 +2348,20 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const origin = point ?? center; const origin = point ?? center;
const size = imageGeneratorPlaceholderSize(composerImageSettings, viewport.k, { width: stageWidth, height: stageHeight }); 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 = { const node: CanvasNode = {
id: cryptoFallbackId(), id: cryptoFallbackId(),
type: "image", type: "image",
title: t("imageGeneratorNode"), title: t("imageGeneratorNode"),
x: origin.x - size.width / 2, x: position.x,
y: origin.y - size.height / 2, y: position.y,
width: size.width, width: size.width,
height: size.height, height: size.height,
content: "", content: "",
tone: "image-generator", tone: "image-generator",
status: "draft", status: "draft",
layerRole: "image-generator" layerRole: "image-generator",
imageAdjustments: serializeImageGeneratorTargetSize(composerImageSettings)
}; };
setComposerMode("image"); setComposerMode("image");
setActiveTool("select"); setActiveTool("select");
@@ -2375,7 +2390,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
x: centerX - size.width / 2, x: centerX - size.width / 2,
y: centerY - size.height / 2, y: centerY - size.height / 2,
width: size.width, width: size.width,
height: size.height height: size.height,
imageAdjustments: serializeImageGeneratorTargetSize(settings, node.imageAdjustments)
}; };
}); });
nodesRef.current = next; nodesRef.current = next;
@@ -2577,7 +2593,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setNodes((current) => { setNodes((current) => {
const next = current.map((node) => const next = current.map((node) =>
node.id === selectedGeneratorNode.id && node.type === "image" && node.layerRole === "image-generator" 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 : node
); );
nodesRef.current = next; nodesRef.current = next;
@@ -3437,6 +3453,12 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const locked = lockedNodeIds.has(node.id); const locked = lockedNodeIds.has(node.id);
const canOperateNode = !locked; const canOperateNode = !locked;
const suppressSelectionFrame = (imageActionMode === "expand" || imageActionMode === "crop") && selectedImageNode?.id === node.id; 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 ( return (
<CanvasNodeView <CanvasNodeView
key={node.id} key={node.id}
@@ -3455,6 +3477,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}} }}
selectionScale={viewport.k} selectionScale={viewport.k}
viewportScale={viewport.k} viewportScale={viewport.k}
imageGeneratorTargetSize={imageGeneratorTargetSize}
hasClipboard hasClipboard
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null} contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
onPointerDown={(event) => handleNodePointerDown(event, node)} onPointerDown={(event) => handleNodePointerDown(event, node)}
@@ -3475,7 +3498,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
<FrameDraftOverlay draft={frameDraft} label={t("newFrameNode")} /> <FrameDraftOverlay draft={frameDraft} label={t("newFrameNode")} />
<ShapeDraftOverlay draft={shapeDraft} kind={selectedShapeKind} label={shapeTitle(selectedShapeKind, t)} /> <ShapeDraftOverlay draft={shapeDraft} kind={selectedShapeKind} label={shapeTitle(selectedShapeKind, t)} />
<PenStrokeDraftOverlay draft={penDraft} /> <PenStrokeDraftOverlay draft={penDraft} />
{selectedImageGeneratorBounds && ( {selectedImageGeneratorBounds && selectedImageGeneratorNode?.status === "draft" && (
<ImageGeneratorFloatingComposer <ImageGeneratorFloatingComposer
bounds={selectedImageGeneratorBounds} bounds={selectedImageGeneratorBounds}
stageWidth={canvasStageSize.width} stageWidth={canvasStageSize.width}
@@ -3484,7 +3507,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
selectedModel={selectedCanvasModel} selectedModel={selectedCanvasModel}
settings={composerImageSettings} settings={composerImageSettings}
generating={selectedImageGeneratorBusy} generating={selectedImageGeneratorBusy}
lockedPrompt={selectedImageGeneratorNode?.status === "generating" ? cleanImageGeneratorPrompt(selectedImageGeneratorNode.content) : ""} lockedPrompt=""
onSettingsChange={updateComposerImageSettings} onSettingsChange={updateComposerImageSettings}
onModelChange={setSelectedCanvasModelId} onModelChange={setSelectedCanvasModelId}
onUploadFile={uploadReferenceToImageGenerator} onUploadFile={uploadReferenceToImageGenerator}
@@ -4006,6 +4029,7 @@ function CanvasNodeView({
transform, transform,
selectionScale, selectionScale,
viewportScale, viewportScale,
imageGeneratorTargetSize,
hasClipboard, hasClipboard,
contextMenuHandlers, contextMenuHandlers,
onPointerDown, onPointerDown,
@@ -4027,6 +4051,7 @@ function CanvasNodeView({
transform: NodeTransform; transform: NodeTransform;
selectionScale: number; selectionScale: number;
viewportScale: number; viewportScale: number;
imageGeneratorTargetSize?: ImageGeneratorTargetSize;
hasClipboard: boolean; hasClipboard: boolean;
contextMenuHandlers: NodeContextMenuHandlers | null; contextMenuHandlers: NodeContextMenuHandlers | null;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void; onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
@@ -4044,11 +4069,13 @@ function CanvasNodeView({
const booleanShapeGroup = isBooleanShapeGroupNode(node); const booleanShapeGroup = isBooleanShapeGroupNode(node);
const lineShapeNode = isLineShapeNode(node); const lineShapeNode = isLineShapeNode(node);
const penStroke = node.type === "image" && node.layerRole === "pen-stroke"; 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 textOverlay = node.type === "text" && node.layerRole === "editable-text";
const textOverlayStyle = textOverlay ? editableTextLayerStyle(node) : undefined; const textOverlayStyle = textOverlay ? editableTextLayerStyle(node) : undefined;
const textEditorRef = useRef<HTMLTextAreaElement | null>(null); 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 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; const SelectionIcon = node.type === "frame" ? Frame : node.type === "text" ? Type : penStroke ? PenLine : ImageIcon;
useEffect(() => { useEffect(() => {
@@ -4080,7 +4107,7 @@ function CanvasNodeView({
{shapeNode || booleanShapeGroup ? ( {shapeNode || booleanShapeGroup ? (
<ShapeNodePreview node={node} /> <ShapeNodePreview node={node} />
) : visual ? ( ) : visual ? (
<ArtboardPreview node={node} viewportScale={viewportScale} /> <ArtboardPreview node={node} viewportScale={viewportScale} imageGeneratorTargetSize={imageGeneratorTargetSize} />
) : textOverlay ? ( ) : textOverlay ? (
editing ? ( editing ? (
<textarea <textarea
@@ -4128,7 +4155,7 @@ function CanvasNodeView({
title={selectionTitle} title={selectionTitle}
size={selectionSize} size={selectionSize}
Icon={SelectionIcon} 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} selectionScale={selectionScale}
transform={transform} transform={transform}
onResizePointerDown={onResizePointerDown} onResizePointerDown={onResizePointerDown}
@@ -5404,6 +5431,56 @@ function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlyS
.sort((a, b) => b.updateTimestamp - a.updateTimestamp)[0]?.agentThreadId || ""; .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) { function agentThreadTitleText(content: string) {
return visiblePromptText(content); return visiblePromptText(content);
} }
+8
View File
@@ -2365,6 +2365,14 @@ button:disabled {
right: calc(10px / var(--selection-scale)); 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 { .pen-stroke-selection-frame .moteva-selection-meta {
display: none; display: none;
} }