From 81b9a207b9c379c9018ebf8d6e08dfe4a1b7a0b3 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Sun, 7 Jun 2026 12:25:52 +0800 Subject: [PATCH] feat(toolbar): add image splitting feature to toolbar for generating sub-images --- CHANGELOG.md | 2 + docs/content/docs/progress/pending-test.mdx | 1 + .../(user)/canvas/[id]/canvas-client-page.tsx | 46 +++++++- .../components/canvas-image-toolbar-tools.tsx | 16 ++- .../components/canvas-node-hover-toolbar.tsx | 4 +- .../components/canvas-node-split-dialog.tsx | 104 ++++++++++++++++++ .../(user)/canvas/utils/canvas-image-data.ts | 30 +++++ 7 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 web/src/app/(user)/canvas/components/canvas-node-split-dialog.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d35052a..6e72bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 新增图片切图功能。 + ## v0.2.4 - 2026-06-04 + [新增] 新增图片反推提示词功能。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index b947de5..c450c6e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。 - 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。 - 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。 - 图片节点悬浮工具栏新增“反推提示词”入口;点击后会在原图右侧创建包含反推预设提示词的文本节点,并继续创建一个已切到“文本”模式的生成配置节点,图片节点和文本节点都会自动连到该配置节点,配置节点会预填包含原图和文本节点引用的组装提示词并自动打开,用户可自行点击配置节点开始生成。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 1f0f5a5..ca3be53 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -19,7 +19,7 @@ import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { UserStatusActions } from "@/components/layout/user-status-actions"; import { useAssetStore } from "@/stores/use-asset-store"; import { useThemeStore } from "@/stores/use-theme-store"; -import { cropDataUrl, upscaleDataUrl } from "../utils/canvas-image-data"; +import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "../utils/canvas-image-data"; import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size"; import { App, Button, Dropdown, Modal } from "antd"; import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants"; @@ -31,6 +31,7 @@ import { CanvasNodeContextMenu } from "../components/canvas-context-menu"; import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog"; import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog"; import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "../components/canvas-node-mask-edit-dialog"; +import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "../components/canvas-node-split-dialog"; import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "../components/canvas-node-upscale-dialog"; import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation"; import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar"; @@ -282,6 +283,7 @@ function InfiniteCanvasPage() { const [infoNodeId, setInfoNodeId] = useState(null); const [cropNodeId, setCropNodeId] = useState(null); const [maskEditNodeId, setMaskEditNodeId] = useState(null); + const [splitNodeId, setSplitNodeId] = useState(null); const [upscaleNodeId, setUpscaleNodeId] = useState(null); const [superResolveNodeId, setSuperResolveNodeId] = useState(null); const [angleNodeId, setAngleNodeId] = useState(null); @@ -586,6 +588,7 @@ function InfiniteCanvasPage() { const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null; const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null; const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null; + const splitNode = splitNodeId ? nodeById.get(splitNodeId) || null : null; const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null; const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null; const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null; @@ -1519,6 +1522,44 @@ function InfiniteCanvasPage() { setCropNodeId(null); }, []); + const splitImageNode = useCallback( + async (node: CanvasNodeData, params: CanvasImageSplitParams) => { + if (!node.metadata?.content) return; + setSplitNodeId(null); + const pieces = await splitDataUrl(node.metadata.content, params); + const gap = 16; + const cellWidth = node.width / params.columns; + const cellHeight = node.height / params.rows; + const startX = node.position.x + node.width + 96; + const startY = node.position.y; + const childNodes = await Promise.all( + pieces.map(async (piece) => { + const image = await uploadImage(piece.dataUrl); + const id = nanoid(); + return { + id, + type: CanvasNodeType.Image, + title: `${node.title || "图片"} ${piece.row + 1}-${piece.column + 1}`, + position: { x: startX + piece.column * (cellWidth + gap), y: startY + piece.row * (cellHeight + gap) }, + width: cellWidth, + height: cellHeight, + metadata: { + ...imageMetadata(image), + prompt: node.metadata?.prompt, + }, + } satisfies CanvasNodeData; + }), + ); + setNodes((prev) => [...prev, ...childNodes]); + setConnections((prev) => [...prev, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]); + setSelectedNodeIds(new Set(childNodes.map((child) => child.id))); + setSelectedConnectionId(null); + setDialogNodeId(null); + message.success(`已切分为 ${childNodes.length} 个子节点`); + }, + [message], + ); + const maskEditImageNode = useCallback( async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { if (!node.metadata?.content) return; @@ -2437,6 +2478,7 @@ function InfiniteCanvasPage() { onSaveAsset={(node) => void saveNodeAsset(node)} onMaskEdit={(node) => setMaskEditNodeId(node.id)} onCrop={(node) => setCropNodeId(node.id)} + onSplit={(node) => setSplitNodeId(node.id)} onUpscale={(node) => setUpscaleNodeId(node.id)} onSuperResolve={(node) => setSuperResolveNodeId(node.id)} onAngle={(node) => setAngleNodeId(node.id)} @@ -2508,6 +2550,8 @@ function InfiniteCanvasPage() { {maskEditNode?.metadata?.content ? setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null} + {splitNode?.metadata?.content ? setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null} + {upscaleNode?.metadata?.content ? setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null} setSuperResolveNodeId(null)}> diff --git a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx index 22232b8..a41cd1d 100644 --- a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx +++ b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx @@ -1,11 +1,11 @@ "use client"; import type { ReactNode } from "react"; -import { Brush, Camera, Copy, FileText, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; +import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; import type { CanvasNodeData } from "../types"; -export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "upscale" | "superResolve" | "angle" | "view"; +export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; export type ImageToolHandlers = { @@ -13,6 +13,7 @@ export type ImageToolHandlers = { onToggleFreeResize: (node: CanvasNodeData) => void; onMaskEdit: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; + onSplit: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; @@ -37,7 +38,7 @@ export type ImageQuickToolsConfig = { showLabels: boolean; }; -export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v5"; +export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v6"; const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"]; @@ -97,6 +98,15 @@ export const imageToolDefinitions: ImageToolDefinition[] = [ icon: () => , run: (node, handlers) => handlers.onCrop(node), }, + { + id: "split", + defaultVisible: true, + panelLabel: "切图", + label: "切图", + title: "按行列切分图片", + icon: () => , + run: (node, handlers) => handlers.onSplit(node), + }, { id: "upscale", defaultVisible: true, diff --git a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx index 9d1c0d7..338423a 100644 --- a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx @@ -28,6 +28,7 @@ type CanvasNodeHoverToolbarProps = { onSaveAsset: (node: CanvasNodeData) => void; onMaskEdit: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; + onSplit: (node: CanvasNodeData) => void; onUpscale: (node: CanvasNodeData) => void; onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; @@ -64,6 +65,7 @@ export function CanvasNodeHoverToolbar({ onSaveAsset, onMaskEdit, onCrop, + onSplit, onUpscale, onSuperResolve, onAngle, @@ -121,7 +123,7 @@ export function CanvasNodeHoverToolbar({ } copyText(prompt, "提示词已复制"); }; - const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); + const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); function openImageToolSettings() { onKeep(node.id); diff --git a/web/src/app/(user)/canvas/components/canvas-node-split-dialog.tsx b/web/src/app/(user)/canvas/components/canvas-node-split-dialog.tsx new file mode 100644 index 0000000..ff05a55 --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-node-split-dialog.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, InputNumber, Modal } from "antd"; +import { Grid2x2 } from "lucide-react"; + +import { readImageMeta } from "@/lib/image-utils"; +import type { ImageSplitParams } from "../utils/canvas-image-data"; + +export type CanvasImageSplitParams = ImageSplitParams; + +const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 }; +const maxGridSize = 12; + +export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) { + const [params, setParams] = useState(defaultParams); + const [image, setImage] = useState<{ width: number; height: number } | null>(null); + const total = params.rows * params.columns; + const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null; + + useEffect(() => { + if (!open) return; + setParams(defaultParams); + setImage(null); + }, [dataUrl, open]); + + useEffect(() => { + if (!open) return; + void readImageMeta(dataUrl).then(setImage); + }, [dataUrl, open]); + + const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => { + setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) })); + }; + + return ( + +
+
+

切分图片

+

生成 {total} 个图片子节点,并按原图网格排列到画布右侧

+
+
+
+
+
+ + +
+
+
+ 原图 + {image ? `${image.width} x ${image.height} px` : "读取中"} +
+
+
+ update("rows", value)} /> + update("columns", value)} /> +
+
+ 子节点 + {total} 个 +
+
+ 单块约 + {pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"} +
+
+ +
+
+
+
+ ); +} + +function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) { + return ( + + ); +} + +function SplitGrid({ rows, columns }: CanvasImageSplitParams) { + return ( +
+ {Array.from({ length: columns - 1 }).map((_, index) => ( +
+ ))} + {Array.from({ length: rows - 1 }).map((_, index) => ( +
+ ))} +
+ ); +} + +function clampGrid(value: string | number) { + const numberValue = Number(value); + return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1))); +} diff --git a/web/src/app/(user)/canvas/utils/canvas-image-data.ts b/web/src/app/(user)/canvas/utils/canvas-image-data.ts index e1bba3b..a91f34d 100644 --- a/web/src/app/(user)/canvas/utils/canvas-image-data.ts +++ b/web/src/app/(user)/canvas/utils/canvas-image-data.ts @@ -23,6 +23,17 @@ export type ImageUpscaleParams = { algorithm: ImageUpscaleAlgorithm; }; +export type ImageSplitParams = { + rows: number; + columns: number; +}; + +export type ImageSplitPiece = { + row: number; + column: number; + dataUrl: string; +}; + export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) { const image = await loadImage(dataUrl); if (crop) { @@ -34,6 +45,25 @@ export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) { return drawCrop(image, sx, sy, size, size); } +export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise { + const image = await loadImage(dataUrl); + const rows = Math.max(1, Math.floor(params.rows)); + const columns = Math.max(1, Math.floor(params.columns)); + const pieces: ImageSplitPiece[] = []; + + for (let row = 0; row < rows; row += 1) { + const sy = Math.floor((row * image.height) / rows); + const sh = Math.floor(((row + 1) * image.height) / rows) - sy; + for (let column = 0; column < columns; column += 1) { + const sx = Math.floor((column * image.width) / columns); + const sw = Math.floor(((column + 1) * image.width) / columns) - sx; + pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) }); + } + } + + return pieces; +} + export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) { const image = await loadImage(dataUrl); const canvas = document.createElement("canvas");