feat(canvas): 图片编辑 - 合并图片编辑工作流并保留近期项目基线

This commit is contained in:
ddCat
2026-09-18 23:54:14 +08:00
72 changed files with 10696 additions and 102 deletions

View File

@@ -1,16 +1,21 @@
import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling } from "lucide-react";
import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Layers3, Lock, LockOpen, Maximize2, PencilLine, Crop, SlidersHorizontal, Smile, Sun, Upload, Scaling, WandSparkles } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas";
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "imageEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onAnnotate: (node: CanvasNodeData) => void;
onAnnotationEdit: (node: CanvasNodeData) => void;
onTextEdit: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onImageEdit: (node: CanvasNodeData) => void;
onRemoveBackground: (node: CanvasNodeData) => void;
onLayerDecomposition: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
onPortraitTexture: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
@@ -84,6 +89,16 @@ const imageToolDefinitions: ImageToolDefinition[] = [
order: 40,
run: (node, handlers) => handlers.onAnnotate(node),
},
{
id: "annotationEdit",
section: "拆分与标记",
description: "用画笔标记区域并让模型按标记修改",
label: "标注编辑",
icon: () => <Brush className="size-3.5" />,
group: "process",
order: 45,
run: (node, handlers) => handlers.onAnnotationEdit(node),
},
{
id: "maskEdit",
label: "局部重绘",
@@ -93,6 +108,42 @@ const imageToolDefinitions: ImageToolDefinition[] = [
order: 10,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "textEdit",
label: "文字编辑",
description: "识别图片中的文字并逐行修改",
icon: () => <FileText className="size-3.5" />,
group: "primary",
order: 15,
run: (node, handlers) => handlers.onTextEdit(node),
},
{
id: "imageEdit",
label: "图片编辑",
description: "用文字描述要修改的内容,生成新图片",
icon: () => <WandSparkles className="size-3.5" />,
group: "primary",
order: 20,
run: (node, handlers) => handlers.onImageEdit(node),
},
{
id: "removeBackground",
label: "去除背景",
description: "保留主体并生成透明背景图片",
icon: () => <WandSparkles className="size-3.5" />,
group: "process",
order: 50,
run: (node, handlers) => handlers.onRemoveBackground(node),
},
{
id: "layerDecomposition",
label: "AI 图层拆分",
description: "识别并拆分为多个可独立编辑的图片图层",
icon: () => <Layers3 className="size-3.5" />,
group: "process",
order: 55,
run: (node, handlers) => handlers.onLayerDecomposition(node),
},
{
id: "emotion",
label: "表情调整",

View File

@@ -8,14 +8,16 @@ import { imageToDataUrl } from "@/services/image-storage";
type Point = { x: number; y: number };
type Stroke = { color: string; size: number; erase: boolean; points: Point[] };
export type CanvasImageAnnotationPayload = { sourceDataUrl: string; annotatedDataUrl: string };
const colors = ["#ef4444", "#f59e0b", "#22c55e", "#14b8a6", "#3b82f6", "#a855f7", "#ffffff", "#111827"];
export function CanvasNodeAnnotationDialog({ image, open, onClose, onConfirm }: {
export function CanvasNodeAnnotationDialog({ image, open, onClose, onConfirm, editMode = false }: {
image: { url: string; storageKey?: string };
open: boolean;
onClose: () => void;
onConfirm: (dataUrl: string) => void;
onConfirm: (dataUrl: string | CanvasImageAnnotationPayload) => void;
editMode?: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const sourceImageRef = useRef<HTMLImageElement | null>(null);
@@ -99,14 +101,15 @@ export function CanvasNodeAnnotationDialog({ image, open, onClose, onConfirm }:
if (!context) return;
context.drawImage(sourceImage, 0, 0, output.width, output.height);
context.drawImage(annotation, 0, 0);
onConfirm(output.toDataURL("image/png"));
const annotatedDataUrl = annotation.toDataURL("image/png");
onConfirm(editMode ? { sourceDataUrl: source, annotatedDataUrl } : output.toDataURL("image/png"));
};
return (
<Modal title={null} open={open} onCancel={onClose} footer={null} width="min(1120px, calc(100vw - 32px))" centered destroyOnHidden>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-2" style={{ borderColor: "rgba(127,127,127,.22)" }}>
<span className="px-1 text-sm font-semibold">标注</span>
<span className="px-1 text-sm font-semibold">{editMode ? "标注编辑" : "标注"}</span>
<span className="mx-1 h-6 w-px bg-current opacity-15" />
<ToolButton title="画笔" active={mode === "brush"} onClick={() => setMode("brush")}><Brush className="size-4" /></ToolButton>
<ToolButton title="橡皮" active={mode === "erase"} onClick={() => setMode("erase")}><Eraser className="size-4" /></ToolButton>
@@ -119,7 +122,7 @@ export function CanvasNodeAnnotationDialog({ image, open, onClose, onConfirm }:
<ToolButton title="重做" disabled={!redoStrokes.length} onClick={redo}><Redo2 className="size-4" /></ToolButton>
<ToolButton title="清空" disabled={!strokes.length} onClick={() => { setStrokes([]); setRedoStrokes([]); }}><RotateCcw className="size-4" /></ToolButton>
<span className="min-w-0 flex-1" />
<Button type="primary" icon={<Save className="size-4" />} disabled={!strokes.length} onClick={save}>保存为新节点</Button>
<Button type="primary" icon={<Save className="size-4" />} disabled={!strokes.length} onClick={save}>{editMode ? "按标注生成" : "保存为新节点"}</Button>
</div>
<div className="flex min-h-[360px] items-center justify-center overflow-hidden rounded-lg bg-black/5 dark:bg-white/[0.03]">
{source && size.width ? (

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { Button, Input, Modal } from "antd";
import { WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { AiConfig } from "@/stores/use-config-store";
import { defaultImageParamsForModel } from "@/lib/model-selection";
export type CanvasImageEditPayload = { prompt: string; generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality">> };
export function CanvasNodeImageEditDialog({
dataUrl,
open,
config,
preset,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
onClose: () => void;
onConfirm: (payload: CanvasImageEditPayload) => void;
config: AiConfig;
preset?: "remove-background" | null;
}) {
const [prompt, setPrompt] = useState(preset === "remove-background" ? "移除图片背景,保留主体完整轮廓、细节和边缘,输出透明背景。" : "");
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
useEffect(() => {
if (open) setPrompt(preset === "remove-background" ? "移除图片背景,保留主体完整轮廓、细节和边缘,输出透明背景。" : "");
if (open) setGenerationConfig(config);
}, [config, open, dataUrl]);
return (
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={860} title="图片编辑">
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_300px]">
<div className="grid min-h-[320px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04]">
<img src={dataUrl} alt="待编辑图片" className="max-h-[58vh] max-w-full object-contain" draggable={false} />
</div>
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">{preset === "remove-background" ? "去除背景" : "描述你要修改的内容"}</h3>
<p className="mt-1 text-sm opacity-60">保留主体和构图,只修改你描述的部分。</p>
</div>
<Input.TextArea
autoFocus
rows={7}
value={prompt}
placeholder="例如:把背景换成黄昏海边,保持人物姿势和服装不变"
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">编辑模型</div>
<ModelPicker
config={generationConfig}
value={generationConfig.imageModel || generationConfig.model}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
/>
</div>
<div className="mt-auto flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button type="primary" icon={<WandSparkles className="size-4" />} disabled={!prompt.trim()} onClick={() => onConfirm({ prompt: prompt.trim(), generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality } })}>
开始编辑
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,131 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Tag } from "antd";
import { Layers3, Plus, RotateCcw, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { AiConfig } from "@/stores/use-config-store";
import { defaultImageParamsForModel } from "@/lib/model-selection";
export type CanvasImageLayerDecompositionPayload = {
prompt: string;
regions?: Array<[number, number, number, number]>;
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality">>;
};
const DEFAULT_PROMPT = "将图片拆分为可独立编辑的图层:识别主要主体、前景、背景和重要物体,每个图层单独输出,保持原图外观和边缘细节,使用透明背景,不要合并不同图层。";
export function CanvasNodeLayerDecompositionDialog({
dataUrl,
open,
config,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
config: AiConfig;
onClose: () => void;
onConfirm: (payload: CanvasImageLayerDecompositionPayload) => void;
}) {
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
const [regions, setRegions] = useState<Array<[number, number, number, number]>>([]);
const [drawing, setDrawing] = useState<{ x: number; y: number } | null>(null);
const [draft, setDraft] = useState<[number, number, number, number] | null>(null);
const imageFrameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
setPrompt(DEFAULT_PROMPT);
setGenerationConfig(config);
setRegions([]);
setDrawing(null);
setDraft(null);
}, [config, dataUrl, open]);
const point = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = imageFrameRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
x: Math.max(0, Math.min(1000, ((event.clientX - rect.left) / Math.max(1, rect.width)) * 1000)),
y: Math.max(0, Math.min(1000, ((event.clientY - rect.top) / Math.max(1, rect.height)) * 1000)),
};
};
const startBox = (event: ReactPointerEvent<HTMLDivElement>) => {
const next = point(event);
if (!next) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDrawing(next);
setDraft([next.x, next.y, next.x, next.y]);
};
const moveBox = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drawing) return;
const next = point(event);
if (!next) return;
setDraft([Math.min(drawing.x, next.x), Math.min(drawing.y, next.y), Math.max(drawing.x, next.x), Math.max(drawing.y, next.y)]);
};
const finishBox = () => {
if (!draft) return;
const [x1, y1, x2, y2] = draft;
if (x2 - x1 >= 2 && y2 - y1 >= 2) setRegions((current) => [...current, [Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)]]);
setDrawing(null);
setDraft(null);
};
const selectedPrompt = regions.length
? `${prompt.trim()}\n\n重点处理用户框选的区域,并分别输出这些区域中的主体为独立透明 PNG 图层。选区坐标(图像 0-1000 坐标系):${regions.map((region, index) => `区域${index + 1} <bbox>${region.join(" ")}</bbox>`).join(";")}`
: prompt.trim();
return (
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={900} title="AI 图层拆分">
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-h-[340px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04]">
<div ref={imageFrameRef} className="relative inline-block max-h-[60vh] max-w-full select-none" onPointerDown={startBox} onPointerMove={moveBox} onPointerUp={finishBox} onPointerCancel={finishBox}>
<img src={dataUrl} alt="待拆分图片" className="block max-h-[60vh] max-w-full object-contain" draggable={false} />
<div className="pointer-events-none absolute inset-0">
{regions.map((region, index) => <RegionBox key={`${region.join("-")}-${index}`} region={region} label={index + 1} />)}
{draft ? <RegionBox region={draft} label={regions.length + 1} draft /> : null}
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">拆分图片图层</h3>
<p className="mt-1 text-sm opacity-60">可在图片上拖拽框选对象,AI 会返回多个透明背景图层,并在画布中自动排列为独立节点。</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tag color={regions.length ? "blue" : "default"}>{regions.length ? `已框选 ${regions.length} 个区域` : "未框选,按描述拆分"}</Tag>
{regions.length ? <Button size="small" icon={<RotateCcw className="size-3.5" />} onClick={() => setRegions([])}>清除选区</Button> : <span className="text-xs opacity-55"><Plus className="mr-1 inline size-3" />在左侧图片上拖动添加选区</span>}
</div>
<Input.TextArea rows={7} value={prompt} placeholder="例如:分别提取人物、产品、前景装饰和背景" onChange={(event) => setPrompt(event.target.value)} />
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">图层拆分模型</div>
<ModelPicker
config={generationConfig}
value={generationConfig.imageModel || generationConfig.model}
capability="image"
fullWidth
showSelectedPrice={false}
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
/>
</div>
<div className="mt-auto flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button type="primary" icon={<Layers3 className="size-4" />} disabled={!selectedPrompt} onClick={() => onConfirm({ prompt: selectedPrompt, regions, generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality } })}>
开始拆分
</Button>
</div>
</div>
</div>
</Modal>
);
}
function RegionBox({ region, label, draft = false }: { region: [number, number, number, number]; label: number; draft?: boolean }) {
const [x1, y1, x2, y2] = region;
return <div className={`absolute rounded-sm border-2 ${draft ? "border-dashed border-blue-500 bg-blue-500/10" : "border-solid border-amber-400 bg-amber-400/10"}`} style={{ left: `${x1 / 10}%`, top: `${y1 / 10}%`, width: `${(x2 - x1) / 10}%`, height: `${(y2 - y1) / 10}%` }}><span className="absolute -left-0.5 -top-0.5 grid size-5 -translate-y-1/2 -translate-x-1/2 place-items-center rounded-full bg-amber-400 text-[11px] font-semibold text-black shadow">{label}</span></div>;
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { Alert, Button, Input, Modal, Spin } from "antd";
import { FileText, RefreshCw, WandSparkles, X } from "lucide-react";
export type CanvasImageTextLine = { original: string; text: string; location: string };
export type CanvasImageTextEditPayload = { lines: CanvasImageTextLine[] };
export function buildCanvasTextEditPrompt(lines: CanvasImageTextLine[]) {
const changed = lines.filter((line) => line.text.trim() && line.text !== line.original);
return `编辑提供的原图,仅修改指定位置的文字。保持原图构图、字体风格、字号、颜色、排版、材质和其他画面细节不变,不要新增水印或其他文字。${changed.map((line) => `在“${line.location}”位置,将“${line.original}”改为“${line.text}”。`).join("\n")}`;
}
export function CanvasNodeTextEditDialog({
dataUrl,
open,
onClose,
onDetect,
onConfirm,
}: {
dataUrl: string;
open: boolean;
onClose: () => void;
onDetect: () => Promise<CanvasImageTextLine[]>;
onConfirm: (payload: CanvasImageTextEditPayload) => void;
}) {
const [lines, setLines] = useState<CanvasImageTextLine[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const detect = async () => {
setLoading(true);
setError("");
try {
const detected = await onDetect();
setLines(detected.map((line) => ({ ...line, text: line.text || line.original })));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "文字识别失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!open) return;
setLines([]);
void detect();
// 只在打开或切换源图时识别,避免输入文字触发重复识别。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataUrl, open]);
const changed = lines.some((line) => line.text.trim() && line.text !== line.original);
return (
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={980} title="图片文字编辑">
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_390px]">
<div className="grid min-h-[360px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04]">
<img src={dataUrl} alt="待编辑图片" className="max-h-[64vh] max-w-full object-contain" draggable={false} />
</div>
<div className="flex min-h-[360px] flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold">识别图片文字</h3>
<p className="mt-1 text-sm opacity-60">逐行修改文字,位置描述只用于帮助模型定位。</p>
</div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} onClick={() => void detect()} disabled={loading}>重新识别</Button>
</div>
{loading ? <div className="flex flex-1 items-center justify-center gap-2 text-sm opacity-65"><Spin size="small" />正在识别图片文字…</div> : error ? <div className="space-y-3"><Alert type="error" showIcon message={error} /><Button onClick={() => void detect()}>重试</Button></div> : lines.length ? <div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">{lines.map((line, index) => <div key={`${line.original}-${index}`} className="rounded-xl border p-3"><div className="mb-2 flex items-center gap-2 text-xs opacity-60"><FileText className="size-3.5" /><span>第 {index + 1} 行 · {line.location}</span></div><Input value={line.text} placeholder={line.original} onChange={(event) => setLines((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, text: event.target.value } : item))} /></div>)}</div> : <div className="flex flex-1 items-center justify-center text-sm opacity-55">没有识别到可编辑文字</div>}
<div className="mt-auto flex justify-end gap-2 border-t pt-3"><Button icon={<X className="size-4" />} onClick={onClose}>取消</Button><Button type="primary" icon={<WandSparkles className="size-4" />} disabled={!changed || loading} onClick={() => onConfirm({ lines })}>生成修改结果</Button></div>
</div>
</div>
</Modal>
);
}

View File

@@ -30,11 +30,16 @@ type CanvasNodeToolbarProps = {
onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void;
onAnnotate: (node: CanvasNodeData) => void;
onAnnotationEdit: (node: CanvasNodeData) => void;
onTextEdit: (node: CanvasNodeData) => void;
onGenerateImage: (node: CanvasNodeData) => void;
onUpload: (node: CanvasNodeData) => void;
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onImageEdit: (node: CanvasNodeData) => void;
onRemoveBackground: (node: CanvasNodeData) => void;
onLayerDecomposition: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
onPortraitTexture: (node: CanvasNodeData) => void;
onCrop: (node: CanvasNodeData) => void;
@@ -91,11 +96,16 @@ export function CanvasNodeToolbar({
onIncreaseFont,
onToggleDialog,
onAnnotate,
onAnnotationEdit,
onTextEdit,
onGenerateImage,
onUpload,
onDownload,
onSaveAsset,
onMaskEdit,
onImageEdit,
onRemoveBackground,
onLayerDecomposition,
onEmotion,
onPortraitTexture,
onCrop,
@@ -221,13 +231,13 @@ export function CanvasNodeToolbar({
}
copyText(prompt, "提示词已复制");
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onImageEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont,
onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload,
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeImageEdit: onImageEdit, onNodeRemoveBackground: onRemoveBackground, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSplit: (target) => onSplit(target, { rows: 2, columns: 2 }), onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
onNodeExtractVideoFrames: onExtractVideoFrames, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoSegments: onTrimVideoSegments, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,

View File

@@ -162,7 +162,7 @@ export function CanvasToolbar({
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {}, onSendSelectionToAgent: () => {},
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
onNodeMaskEdit: () => {}, onNodeImageEdit: () => {}, onNodeRemoveBackground: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoFrames: () => {}, onNodeExtractAudioFromVideo: () => {}, onNodeTrimVideoSegments: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {},
onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {},
} as ToolbarHandlers;

View File

@@ -30,6 +30,7 @@ type InfiniteCanvasProps = {
const CANVAS_WHEEL_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-canvas-wheel-scroll],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
const CANVAS_POINTER_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-connection-create-menu],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
const CANVAS_INTERNAL_DRAG_SELECTOR = "[data-canvas-batch-table]";
const WHEEL_ZOOM_DELTA = 72;
const TRACKPAD_PINCH_ZOOM_DELTA = 24;
@@ -441,13 +442,38 @@ export function InfiniteCanvas({ interactive = true, containerRef, viewport, app
if (!target?.closest("[data-node-id],[data-connection-id],[data-canvas-no-zoom]")) onCanvasDoubleClick?.(event);
}}
onContextMenu={onContextMenu}
onDragEnter={onFileDragEnter}
onDragLeave={onFileDragLeave}
onDragEnter={(event) => {
if (isCanvasInternalDragEvent(event)) {
event.preventDefault();
onFileDragEnter?.(event);
return;
}
onFileDragEnter?.(event);
}}
onDragLeave={(event) => {
if (isCanvasInternalDragEvent(event)) {
event.preventDefault();
onFileDragLeave?.(event);
return;
}
onFileDragLeave?.(event);
}}
onDragOver={(event) => {
if (isCanvasInternalDragEvent(event)) {
event.preventDefault();
onFileDragOver?.(event);
return;
}
event.preventDefault();
onFileDragOver?.(event);
}}
onDrop={onDrop}
onDrop={(event) => {
if (isCanvasInternalDragEvent(event)) {
event.preventDefault();
return;
}
onDrop?.(event);
}}
>
<CanvasGrid appearance={appearance} mode={backgroundMode} />
{graphicsLayer}
@@ -463,6 +489,11 @@ export function InfiniteCanvas({ interactive = true, containerRef, viewport, app
);
}
function isCanvasInternalDragEvent(event: React.DragEvent<HTMLDivElement>) {
const target = event.target instanceof Element ? event.target : null;
return Boolean(target?.closest(CANVAS_INTERNAL_DRAG_SELECTOR));
}
function CanvasGrid({ appearance, mode }: { appearance?: CanvasAppearance; mode: CanvasBackgroundMode }) {
const colorTheme = useActiveTheme();
const gridColor = resolveCanvasGridColor(appearance, colorTheme, mode);