feat(canvas): 图片编辑 - 合并图片编辑工作流并保留近期项目基线
This commit is contained in:
@@ -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: "表情调整",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
72
web/src/components/canvas/canvas-node-image-edit-dialog.tsx
Normal file
72
web/src/components/canvas/canvas-node-image-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
73
web/src/components/canvas/canvas-node-text-edit-dialog.tsx
Normal file
73
web/src/components/canvas/canvas-node-text-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user