mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): enhance multi-image layout to support up to four columns and add independent download actions for images
This commit is contained in:
+12
-15
@@ -2,21 +2,18 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
+ [新增] 画布节点提示词输入支持弹窗放大编辑,长提示词和多行内容可在更大空间内修改并实时同步。
|
+ [新增] 画布节点提示词支持弹窗放大编辑和实时同步。
|
||||||
+ [修复] 已有图片或文本节点的编辑提示词在关闭输入面板后继续保留,不再因点击画布空白处丢失。
|
+ [新增] 配置页新增本地存储、站点配额与对象仓库占用信息。
|
||||||
+ [修复] 选择模式下点击画布空白处会同步收起节点顶部工具条和下方输入面板。
|
+ [新增] 画布多图支持失败项重试与删除、创建副本和单图下载。
|
||||||
+ [新增] 配置页新增本地存储面板,可查看 Infinite Canvas 的 IndexedDB、站点配额和各对象仓库占用情况。
|
+ [优化] 画布新增选择与移动模式,并支持 Control 或空格临时反转工具。
|
||||||
+ [修复] 本地图片清理时保留生图与视频工作台历史引用的文件,避免生成记录仍在但图片丢失。
|
+ [优化] 画布多图合并为可展开的单节点,支持实时状态、主图切换和四列布局。
|
||||||
+ [修复] 画布将空格键专用于临时切换工具,避免按钮保留焦点后按空格被再次触发。
|
+ [优化] 从左侧元素列表定位节点时,自动缩放上限调整为 100%。
|
||||||
+ [优化] 画布默认改为拖动轻量虚线框选,虚线显示不受画布缩放影响,左下工具可切换选择与移动模式,并支持按住 Control 或空格临时反转当前模式。
|
+ [修复] 关闭输入面板后保留图片与文本节点的编辑提示词。
|
||||||
+ [优化] 画布多图生成结果合并为单个图片节点,生成期间可展开查看每张图片的实时状态,优先展示最先成功的结果,并以紧凑堆叠展示已有图片。
|
+ [修复] 点击画布空白处时同步收起节点工具条和输入面板。
|
||||||
+ [优化] 从左侧元素列表定位画布节点时,自动缩放上限调整为 100%。
|
+ [修复] 空格键切换工具时不再误触当前聚焦的按钮。
|
||||||
+ [修复] 画布多图切换主图时保持节点当前视觉尺度和中心位置,避免节点突然放大造成视口缩放错觉。
|
+ [修复] 清理本地图片时保留工作台生成历史引用的文件。
|
||||||
+ [新增] 画布多图展开后支持单独重新生成或删除失败的图片槽位。
|
+ [修复] 修复画布多图切换主图、重试和调整尺寸时的缩放与状态异常。
|
||||||
+ [修复] 画布多图失败槽位重试时正确传递图片标识,避免触发未定义变量错误。
|
+ [修复] 统一画布多图操作按钮在不同主题下的颜色与对比度。
|
||||||
+ [修复] 调整展开的多图节点尺寸时先收起子图并停止清理阶段的状态回写,避免 React 循环更新崩溃。
|
|
||||||
+ [新增] 画布多图备选图片支持创建为独立图片节点。
|
|
||||||
+ [修复] 统一画布多图备选操作按钮的主题背景与文字颜色,避免按钮内容对比度不足。
|
|
||||||
|
|
||||||
## v0.14.0 - 2026-08-05
|
## v0.14.0 - 2026-08-05
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The current release needs manual verification in these areas:
|
|||||||
- 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.
|
- 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.
|
- 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: starting a resize while child images are expanded should collapse the group once, then resize the root node normally without triggering nested React updates or an application error.
|
- Multi-image resize: starting a resize while child images are expanded should collapse the group once, then resize the root node normally without triggering nested React updates or an application error.
|
||||||
- Multi-image alternatives: every completed expanded alternative should provide 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; 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.
|
- 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 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.
|
- Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ChevronRight, Copy, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Trash2, Video } from "lucide-react";
|
import { ChevronRight, Copy, Download, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Trash2, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes } from "@/lib/image-utils";
|
import { formatBytes } from "@/lib/image-utils";
|
||||||
@@ -48,6 +48,7 @@ type CanvasNodeProps = {
|
|||||||
onToggleBatch?: (nodeId: string) => void;
|
onToggleBatch?: (nodeId: string) => void;
|
||||||
onSetBatchPrimary?: (nodeId: string, imageId: string) => void;
|
onSetBatchPrimary?: (nodeId: string, imageId: string) => void;
|
||||||
onDuplicateBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
onDuplicateBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
||||||
|
onDownloadBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
||||||
onRetryBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
onRetryBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
||||||
onDeleteBatchImage?: (nodeId: string, imageId: string) => void;
|
onDeleteBatchImage?: (nodeId: string, imageId: string) => void;
|
||||||
onRetry?: (node: CanvasNodeData) => void;
|
onRetry?: (node: CanvasNodeData) => void;
|
||||||
@@ -74,6 +75,7 @@ type NodeContentRendererProps = {
|
|||||||
onToggleBatch?: () => void;
|
onToggleBatch?: () => void;
|
||||||
onSetBatchPrimary?: (imageId: string) => void;
|
onSetBatchPrimary?: (imageId: string) => void;
|
||||||
onDuplicateBatchImage?: (imageId: string) => void;
|
onDuplicateBatchImage?: (imageId: string) => void;
|
||||||
|
onDownloadBatchImage?: (imageId: string) => void;
|
||||||
onRetryBatchImage?: (imageId: string) => void;
|
onRetryBatchImage?: (imageId: string) => void;
|
||||||
onDeleteBatchImage?: (imageId: string) => void;
|
onDeleteBatchImage?: (imageId: string) => void;
|
||||||
groupChildCount: number;
|
groupChildCount: number;
|
||||||
@@ -110,6 +112,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
onToggleBatch,
|
onToggleBatch,
|
||||||
onSetBatchPrimary,
|
onSetBatchPrimary,
|
||||||
onDuplicateBatchImage,
|
onDuplicateBatchImage,
|
||||||
|
onDownloadBatchImage,
|
||||||
onRetryBatchImage,
|
onRetryBatchImage,
|
||||||
onDeleteBatchImage,
|
onDeleteBatchImage,
|
||||||
onRetry,
|
onRetry,
|
||||||
@@ -408,6 +411,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||||
onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)}
|
onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)}
|
||||||
onDuplicateBatchImage={(imageId) => onDuplicateBatchImage?.(data, imageId)}
|
onDuplicateBatchImage={(imageId) => onDuplicateBatchImage?.(data, imageId)}
|
||||||
|
onDownloadBatchImage={(imageId) => onDownloadBatchImage?.(data, imageId)}
|
||||||
onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)}
|
onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)}
|
||||||
onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)}
|
onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)}
|
||||||
groupChildCount={groupChildCount}
|
groupChildCount={groupChildCount}
|
||||||
@@ -583,6 +587,7 @@ function ImageNodeContent(props: NodeContentRendererProps) {
|
|||||||
onToggleBatch={props.onToggleBatch}
|
onToggleBatch={props.onToggleBatch}
|
||||||
onSetBatchPrimary={props.onSetBatchPrimary}
|
onSetBatchPrimary={props.onSetBatchPrimary}
|
||||||
onDuplicateBatchImage={props.onDuplicateBatchImage}
|
onDuplicateBatchImage={props.onDuplicateBatchImage}
|
||||||
|
onDownloadBatchImage={props.onDownloadBatchImage}
|
||||||
onRetryBatchImage={props.onRetryBatchImage}
|
onRetryBatchImage={props.onRetryBatchImage}
|
||||||
onDeleteBatchImage={props.onDeleteBatchImage}
|
onDeleteBatchImage={props.onDeleteBatchImage}
|
||||||
/>
|
/>
|
||||||
@@ -639,6 +644,7 @@ function ImageContent({
|
|||||||
onToggleBatch,
|
onToggleBatch,
|
||||||
onSetBatchPrimary,
|
onSetBatchPrimary,
|
||||||
onDuplicateBatchImage,
|
onDuplicateBatchImage,
|
||||||
|
onDownloadBatchImage,
|
||||||
onRetryBatchImage,
|
onRetryBatchImage,
|
||||||
onDeleteBatchImage,
|
onDeleteBatchImage,
|
||||||
}: {
|
}: {
|
||||||
@@ -647,6 +653,7 @@ function ImageContent({
|
|||||||
onToggleBatch?: () => void;
|
onToggleBatch?: () => void;
|
||||||
onSetBatchPrimary?: (imageId: string) => void;
|
onSetBatchPrimary?: (imageId: string) => void;
|
||||||
onDuplicateBatchImage?: (imageId: string) => void;
|
onDuplicateBatchImage?: (imageId: string) => void;
|
||||||
|
onDownloadBatchImage?: (imageId: string) => void;
|
||||||
onRetryBatchImage?: (imageId: string) => void;
|
onRetryBatchImage?: (imageId: string) => void;
|
||||||
onDeleteBatchImage?: (imageId: string) => void;
|
onDeleteBatchImage?: (imageId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -664,7 +671,7 @@ function ImageContent({
|
|||||||
{batchExpanded
|
{batchExpanded
|
||||||
? images
|
? images
|
||||||
.filter((image) => image.id !== primaryImageId)
|
.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)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
|
.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)} />)
|
||||||
: null}
|
: null}
|
||||||
<div className="h-full w-full overflow-hidden rounded-3xl">
|
<div className="h-full w-full overflow-hidden rounded-3xl">
|
||||||
{primaryContent ? (
|
{primaryContent ? (
|
||||||
@@ -680,6 +687,12 @@ function ImageContent({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryImage?.status === "error" ? <BatchImageFailureActions placement="left" onRetry={() => onRetryBatchImage?.(primaryImage.id)} onDelete={() => onDeleteBatchImage?.(primaryImage.id)} /> : null}
|
{primaryImage?.status === "error" ? <BatchImageFailureActions placement="left" onRetry={() => onRetryBatchImage?.(primaryImage.id)} onDelete={() => onDeleteBatchImage?.(primaryImage.id)} /> : null}
|
||||||
|
{primaryImage?.content ? (
|
||||||
|
<button type="button" className="absolute left-2.5 top-2.5 z-30 flex h-8 items-center gap-1 rounded-lg border px-2 text-[10px] font-medium shadow-[0_6px_18px_rgba(15,23,42,.16)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} title={t("common.download")} onClick={(event) => (event.stopPropagation(), onDownloadBatchImage?.(primaryImage.id))}>
|
||||||
|
<Download className="size-3" />
|
||||||
|
{t("common.download")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{isBatchRoot ? (
|
{isBatchRoot ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -701,15 +714,16 @@ function ImageContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpandedImageCard({ node, image, index, onSetPrimary, onDuplicate, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onDuplicate: () => void; onRetry: () => void; onDelete: () => void }) {
|
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 }) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const count = node.metadata?.images?.length || 0;
|
const count = node.metadata?.images?.length || 0;
|
||||||
const rows = Math.ceil(count / 2);
|
const columns = Math.min(count, 4);
|
||||||
const rootSlot = (rows - 1) * 2;
|
const rows = Math.ceil(count / columns);
|
||||||
|
const rootSlot = (rows - 1) * columns;
|
||||||
const slot = index >= rootSlot ? index + 1 : index;
|
const slot = index >= rootSlot ? index + 1 : index;
|
||||||
const column = slot % 2;
|
const column = slot % columns;
|
||||||
const row = Math.floor(slot / 2);
|
const row = Math.floor(slot / columns);
|
||||||
const x = column * (node.width + 18);
|
const x = column * (node.width + 18);
|
||||||
const y = (row - rows + 1) * (node.height + 18);
|
const y = (row - rows + 1) * (node.height + 18);
|
||||||
|
|
||||||
@@ -736,14 +750,18 @@ function ExpandedImageCard({ node, image, index, onSetPrimary, onDuplicate, onRe
|
|||||||
>
|
>
|
||||||
{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 ? <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 ? (
|
{image.content ? (
|
||||||
<div className="absolute right-3 top-3 flex items-center gap-1.5">
|
<div className="absolute inset-x-2 top-2 flex items-center gap-1">
|
||||||
<button type="button" className="flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium shadow-[0_8px_20px_rgba(15,23,42,.18)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} onClick={(event) => (event.stopPropagation(), onDuplicate())}>
|
<button type="button" className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1 rounded-lg border px-1.5 text-[10px] font-medium shadow-[0_6px_18px_rgba(15,23,42,.16)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} title={t("common.download")} onClick={(event) => (event.stopPropagation(), onDownload())}>
|
||||||
<Copy className="size-3.5" />
|
<Download className="size-3 shrink-0" />
|
||||||
{t("canvas.node.createCopy")}
|
<span className="truncate">{t("common.download")}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium shadow-[0_8px_20px_rgba(15,23,42,.18)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} onClick={(event) => (event.stopPropagation(), onSetPrimary())}>
|
<button type="button" className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1 rounded-lg border px-1.5 text-[10px] font-medium shadow-[0_6px_18px_rgba(15,23,42,.16)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} title={t("canvas.node.createCopy")} onClick={(event) => (event.stopPropagation(), onDuplicate())}>
|
||||||
<Star className="size-3.5" style={{ color: selectionBlue }} />
|
<Copy className="size-3 shrink-0" />
|
||||||
{t("canvas.node.setPrimary")}
|
<span className="truncate">{t("canvas.node.createCopy")}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1 rounded-lg border px-1.5 text-[10px] font-medium shadow-[0_6px_18px_rgba(15,23,42,.16)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} title={t("canvas.node.setPrimary")} onClick={(event) => (event.stopPropagation(), onSetPrimary())}>
|
||||||
|
<Star className="size-3 shrink-0" style={{ color: selectionBlue }} />
|
||||||
|
<span className="truncate">{t("canvas.node.setPrimary")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1540,6 +1540,12 @@ function InfiniteCanvasPage() {
|
|||||||
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
|
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const downloadBatchImage = useCallback((node: CanvasNodeData, imageId: string) => {
|
||||||
|
const image = node.metadata?.images?.find((item) => item.id === imageId);
|
||||||
|
if (!image?.content) return;
|
||||||
|
saveAs(image.content, `canvas-image-${node.id}-${image.id}.${imageExtension(image.content)}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveNodeAsset = useCallback(
|
const saveNodeAsset = useCallback(
|
||||||
async (node: CanvasNodeData) => {
|
async (node: CanvasNodeData) => {
|
||||||
if (node.type === CanvasNodeType.Text) {
|
if (node.type === CanvasNodeType.Text) {
|
||||||
@@ -2852,6 +2858,7 @@ function InfiniteCanvasPage() {
|
|||||||
onToggleBatch={toggleBatchExpanded}
|
onToggleBatch={toggleBatchExpanded}
|
||||||
onSetBatchPrimary={setBatchPrimary}
|
onSetBatchPrimary={setBatchPrimary}
|
||||||
onDuplicateBatchImage={duplicateBatchImage}
|
onDuplicateBatchImage={duplicateBatchImage}
|
||||||
|
onDownloadBatchImage={downloadBatchImage}
|
||||||
onRetryBatchImage={retryBatchImage}
|
onRetryBatchImage={retryBatchImage}
|
||||||
onDeleteBatchImage={deleteBatchImage}
|
onDeleteBatchImage={deleteBatchImage}
|
||||||
onRetry={handleNodeRetry}
|
onRetry={handleNodeRetry}
|
||||||
|
|||||||
Reference in New Issue
Block a user