feat(canvas): enhance multi-image generation controls and improve image preview behavior
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
+ [新增] 提示词来源新增 Freestylefly GPT Image 2 内置来源。
|
||||
+ [优化] 画布多图展开状态改为由用户主动控制,操作画布其他区域时不再自动收起。
|
||||
+ [调整] 画布默认使用移动工具,可直接拖动元素或空白画布,按住 Control 可临时框选节点。
|
||||
+ [修复] 节点缩放期间暂停渲染提示词面板,避免导入图片后立即缩放导致页面崩溃。
|
||||
+ [调整] 双击多图节点统一放大预览图片,仅通过右上角张数按钮展开或收起图片组。
|
||||
+ [优化] 展开的多图子图支持双击直接放大预览,无需先设为主图。
|
||||
|
||||
## v0.15.1 - 2026-08-07
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ The current release needs manual verification in these areas:
|
||||
- Global Dropdown, Menu, Select, Cascader, and TreeSelect popup backgrounds, hover states, and selected states in both light and dark themes.
|
||||
- Canvas node resize stability: importing an image should open its prompt panel, resizing it immediately from any corner should temporarily hide the panel, remain smooth without flashing or triggering a maximum update depth error, and restore the panel after release. Prompt scrolling, generated-prompt restoration, edit-prompt persistence after closing an existing image or text node panel, expanded prompt editing with live synchronization and `@` references, image editing, drag-and-drop references, and generation configuration should continue to work.
|
||||
- Canvas side panel: clicking an element row should smoothly center and select its node without zooming above 100%; image elements with content should still open the large preview without triggering canvas focus.
|
||||
- Multi-image canvas generation: starting N images should immediately create one image node with N expandable slots and one incoming connection; each empty slot should independently show generating or failure while requests are running, every slot with image content should always display that image, and the first successful image should appear as the primary image immediately. The collapsed state should use a compact stack with at most three closely spaced backplates and a clearly legible count control; clicking the control should hide the floating toolbar and expand the slots upward and to the right. Every completed secondary image should keep a clearly legible primary-image action visible in both themes. Expanded groups should remain expanded when clicking elsewhere, dragging, or resizing, multiple groups may remain expanded together, and only an explicit click or double-click should collapse them.
|
||||
- Multi-image canvas generation: starting N images should immediately create one image node with N expandable slots and one incoming connection; each empty slot should independently show generating or failure while requests are running, every slot with image content should always display that image, and the first successful image should appear as the primary image immediately. The collapsed state should use a compact stack with at most three closely spaced backplates and a clearly legible count control; only clicking the count control should expand or collapse the slots upward and to the right, while double-clicking the image should open its large preview. Every completed secondary image should keep a clearly legible primary-image action visible in both themes. Expanded groups should remain expanded when clicking elsewhere, dragging, resizing, or previewing, and multiple groups may remain expanded together.
|
||||
- Multi-image primary selection: choosing another primary image should preserve the node center and current maximum edge while adapting to the new image ratio; free-resize nodes should keep their exact dimensions, and the viewport zoom indicator must not change.
|
||||
- Failed multi-image slots: every failed slot, including the current primary slot, should expose retry and delete actions. Retry should update only that slot without replacing an existing successful primary image; delete should remove only that failed slot and update the displayed count.
|
||||
- Multi-image resize: resizing the root node while child images are expanded should keep the group expanded and resize normally without triggering nested React updates or an application error.
|
||||
- Multi-image alternatives: expanded images should use up to four columns so larger batches stay compact; every completed image, including the primary image, should provide an independent download action, while alternatives should also keep clearly legible create-copy and set-primary actions in both themes. Create-copy should add an independent image node to the right of the group, select it, and leave the original image group unchanged.
|
||||
- Multi-image alternatives: expanded images should use up to four columns so larger batches stay compact; double-clicking any completed child image should preview that exact image without changing the primary image. Every completed image, including the primary image, should provide an independent download action, while alternatives should also keep clearly legible create-copy and set-primary actions in both themes. Create-copy should add an independent image node to the right of the group, select it, and leave the original image group unchanged.
|
||||
- Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management.
|
||||
- Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped.
|
||||
- Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior.
|
||||
|
||||
@@ -53,7 +53,7 @@ type CanvasNodeProps = {
|
||||
onDeleteBatchImage?: (nodeId: string, imageId: string) => void;
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onViewImage?: (node: CanvasNodeData) => void;
|
||||
onViewImage?: (node: CanvasNodeData, imageId?: string) => void;
|
||||
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@ type NodeContentRendererProps = {
|
||||
onDownloadBatchImage?: (imageId: string) => void;
|
||||
onRetryBatchImage?: (imageId: string) => void;
|
||||
onDeleteBatchImage?: (imageId: string) => void;
|
||||
onViewBatchImage?: (imageId: string) => void;
|
||||
groupChildCount: number;
|
||||
};
|
||||
|
||||
@@ -365,11 +366,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
}}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
if (isBatchRoot) {
|
||||
event.stopPropagation();
|
||||
onToggleBatch?.(data.id);
|
||||
return;
|
||||
}
|
||||
if (definition?.onDoubleClick && pluginContext) {
|
||||
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
|
||||
return;
|
||||
@@ -414,6 +410,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onDownloadBatchImage={(imageId) => onDownloadBatchImage?.(data, imageId)}
|
||||
onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)}
|
||||
onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)}
|
||||
onViewBatchImage={(imageId) => onViewImage?.(data, imageId)}
|
||||
groupChildCount={groupChildCount}
|
||||
/>
|
||||
</div>
|
||||
@@ -590,6 +587,7 @@ function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
onDownloadBatchImage={props.onDownloadBatchImage}
|
||||
onRetryBatchImage={props.onRetryBatchImage}
|
||||
onDeleteBatchImage={props.onDeleteBatchImage}
|
||||
onViewBatchImage={props.onViewBatchImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -647,6 +645,7 @@ function ImageContent({
|
||||
onDownloadBatchImage,
|
||||
onRetryBatchImage,
|
||||
onDeleteBatchImage,
|
||||
onViewBatchImage,
|
||||
}: {
|
||||
node: CanvasNodeData;
|
||||
batchExpanded: boolean;
|
||||
@@ -656,6 +655,7 @@ function ImageContent({
|
||||
onDownloadBatchImage?: (imageId: string) => void;
|
||||
onRetryBatchImage?: (imageId: string) => void;
|
||||
onDeleteBatchImage?: (imageId: string) => void;
|
||||
onViewBatchImage?: (imageId: string) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
@@ -667,11 +667,11 @@ function ImageContent({
|
||||
const primaryContent = primaryImage?.content || node.metadata?.content;
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} onToggleBatch={onToggleBatch}>
|
||||
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded}>
|
||||
{batchExpanded
|
||||
? images
|
||||
.filter((image) => image.id !== primaryImageId)
|
||||
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} onDuplicate={() => onDuplicateBatchImage?.(image.id)} onDownload={() => onDownloadBatchImage?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
|
||||
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onView={() => onViewBatchImage?.(image.id)} onSetPrimary={() => onSetBatchPrimary?.(image.id)} onDuplicate={() => onDuplicateBatchImage?.(image.id)} onDownload={() => onDownloadBatchImage?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
|
||||
: null}
|
||||
<div className="h-full w-full overflow-hidden rounded-3xl">
|
||||
{primaryContent ? (
|
||||
@@ -714,7 +714,7 @@ function ImageContent({
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedImageCard({ node, image, index, onSetPrimary, onDuplicate, onDownload, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onDuplicate: () => void; onDownload: () => void; onRetry: () => void; onDelete: () => void }) {
|
||||
function ExpandedImageCard({ node, image, index, onView, onSetPrimary, onDuplicate, onDownload, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onView: () => void; onSetPrimary: () => void; onDuplicate: () => void; onDownload: () => void; onRetry: () => void; onDelete: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
const count = node.metadata?.images?.length || 0;
|
||||
@@ -746,7 +746,11 @@ function ExpandedImageCard({ node, image, index, onSetPrimary, onDuplicate, onDo
|
||||
}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => {
|
||||
if (!image.content || (event.target instanceof Element && event.target.closest("button"))) return;
|
||||
event.stopPropagation();
|
||||
onView();
|
||||
}}
|
||||
>
|
||||
{image.content ? <img src={image.content} alt={node.title} draggable={false} className="pointer-events-none h-full w-full select-none object-contain" /> : <ImageSlotStatus image={image} />}
|
||||
{image.content ? (
|
||||
@@ -812,21 +816,11 @@ function ImageInfoBar({ node }: { node: CanvasNodeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BatchFrame({ batchCount, batchExpanded, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; onToggleBatch?: () => void; children: ReactNode }) {
|
||||
function BatchFrame({ batchCount, batchExpanded, children }: { batchCount: number; batchExpanded: boolean; children: ReactNode }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const isBatchRoot = batchCount > 1;
|
||||
return (
|
||||
<div
|
||||
className="group/batch relative h-full w-full overflow-visible"
|
||||
onDoubleClick={
|
||||
isBatchRoot
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
onToggleBatch?.();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="group/batch relative h-full w-full overflow-visible">
|
||||
{isBatchRoot ? (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-visible">
|
||||
{Array.from({ length: Math.min(batchCount - 1, 3) }).map((_, index) => (
|
||||
|
||||
@@ -232,6 +232,7 @@ function InfiniteCanvasPage() {
|
||||
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
|
||||
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
|
||||
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
|
||||
const [previewImageId, setPreviewImageId] = useState<string | null>(null);
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
|
||||
@@ -590,6 +591,7 @@ function InfiniteCanvasPage() {
|
||||
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
|
||||
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
|
||||
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
|
||||
const previewContent = previewImageId ? previewNode?.metadata?.images?.find((image) => image.id === previewImageId)?.content : previewNode?.metadata?.content;
|
||||
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
|
||||
const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null);
|
||||
const groupChildCountById = useMemo(() => {
|
||||
@@ -2693,7 +2695,10 @@ function InfiniteCanvasPage() {
|
||||
const handleNodeHoverEnd = useCallback((nodeId: string) => {
|
||||
setHoveredNodeId((current) => (current === nodeId ? null : current));
|
||||
}, []);
|
||||
const handleNodeViewImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
|
||||
const handleNodeViewImage = useCallback((node: CanvasNodeData, imageId?: string) => {
|
||||
setPreviewNodeId(node.id);
|
||||
setPreviewImageId(imageId || null);
|
||||
}, []);
|
||||
const handleNodeRetry = useCallback((node: CanvasNodeData) => void handleRetryNode(node), [handleRetryNode]);
|
||||
const handleNodeContextMenu = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
||||
event.preventDefault();
|
||||
@@ -2918,7 +2923,7 @@ function InfiniteCanvasPage() {
|
||||
onUpscale={(node) => setUpscaleNodeId(node.id)}
|
||||
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
|
||||
onAngle={(node) => setAngleNodeId(node.id)}
|
||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||
onViewImage={handleNodeViewImage}
|
||||
onReversePrompt={createImageReversePromptNodes}
|
||||
onRetry={(node) => void handleRetryNode(node)}
|
||||
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
|
||||
@@ -2998,14 +3003,14 @@ function InfiniteCanvasPage() {
|
||||
|
||||
<Modal
|
||||
title={t("canvas.projectPage.imageDetails")}
|
||||
open={Boolean(previewNode?.metadata?.content)}
|
||||
open={Boolean(previewContent)}
|
||||
centered
|
||||
onCancel={() => setPreviewNodeId(null)}
|
||||
footer={null}
|
||||
width="auto"
|
||||
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }}
|
||||
>
|
||||
{previewNode?.metadata?.content ? <img src={previewNode.metadata.content} alt={previewNode.title || t("assets.kinds.image")} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null}
|
||||
{previewContent ? <img src={previewContent} alt={previewNode?.title || t("assets.kinds.image")} style={{ maxWidth: "100%", maxHeight: "80vh", objectFit: "contain" }} /> : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
|
||||
Reference in New Issue
Block a user