feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
897981b893
commit
6c1d17dbf6
26 files changed
+999
-236
No files matched your search
@@ -1,12 +1,19 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, Input, Modal, Slider } from "antd";
|
||||
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
|
||||
import { Brush, ChevronDown, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { ImageSettingsPanel } from "@/components/image-settings-panel";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultImageParamsForModel } from "@/lib/model-selection";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export type CanvasImageMaskEditPayload = {
|
||||
prompt: string;
|
||||
maskDataUrl: string;
|
||||
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality" | "count" | "transparentBackground">>;
|
||||
};
|
||||
|
||||
type DrawMode = "paint" | "erase";
|
||||
@@ -15,7 +22,7 @@ const defaultBrushSize = 100;
|
||||
const maskFillColor = "rgba(37, 99, 235, .38)";
|
||||
const maskBorderColor = "rgba(255, 255, 255, .72)";
|
||||
|
||||
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
|
||||
export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onConfirm }: { dataUrl: string; open: boolean; config: AiConfig; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
|
||||
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
|
||||
@@ -24,6 +31,9 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
const [brushSize, setBrushSize] = useState(defaultBrushSize);
|
||||
const [mode, setMode] = useState<DrawMode>("paint");
|
||||
const [error, setError] = useState("");
|
||||
const [generationConfig, setGenerationConfig] = useState<AiConfig>(() => config);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -31,8 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
setBrushSize(defaultBrushSize);
|
||||
setMode("paint");
|
||||
setError("");
|
||||
setAdvancedOpen(false);
|
||||
setGenerationConfig(config);
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
}, [config, dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
clearCanvas(maskCanvasRef.current);
|
||||
@@ -95,12 +107,12 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
if (!nextPrompt) return setError("请输入修改要求");
|
||||
if (!canvas) return;
|
||||
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
|
||||
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
|
||||
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas), generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality, count: generationConfig.count, transparentBackground: generationConfig.transparentBackground } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal className="workspace-modal workspace-modal-wide" title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_340px]">
|
||||
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
|
||||
@@ -122,45 +134,93 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[360px] flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">局部重绘</h2>
|
||||
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
画笔
|
||||
</Button>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
擦除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium opacity-75">笔刷大小</span>
|
||||
<span className="font-semibold">{brushSize}px</span>
|
||||
<div className="flex max-h-[68vh] min-h-[360px] flex-col overflow-hidden">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">局部重绘</h2>
|
||||
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||
</div>
|
||||
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-75">修改要求</div>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={prompt}
|
||||
status={error && !prompt.trim() ? "error" : undefined}
|
||||
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
画笔
|
||||
</Button>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
擦除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium opacity-75">笔刷大小</span>
|
||||
<span className="font-semibold">{brushSize}px</span>
|
||||
</div>
|
||||
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-75">修改要求</div>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={prompt}
|
||||
status={error && !prompt.trim() ? "error" : undefined}
|
||||
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
|
||||
</div>
|
||||
|
||||
<ImageSettingsPanel
|
||||
config={generationConfig}
|
||||
showTitle={false}
|
||||
showQuality={false}
|
||||
showTransparent={false}
|
||||
showSize={false}
|
||||
className="space-y-2"
|
||||
theme={theme}
|
||||
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
|
||||
/>
|
||||
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
|
||||
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((current) => !current)}
|
||||
>
|
||||
<span>高级生成设置</span>
|
||||
<ChevronDown className={`size-4 shrink-0 opacity-60 transition-transform ${advancedOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-3 pb-3 pt-3">
|
||||
<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>
|
||||
<ImageSettingsPanel
|
||||
config={generationConfig}
|
||||
showTitle={false}
|
||||
showCount={false}
|
||||
className="space-y-3"
|
||||
theme={theme}
|
||||
onConfigChange={(key, value) => setGenerationConfig((current) => ({ ...current, [key]: value }))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between gap-2 border-t border-border/50 pt-3">
|
||||
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
|
||||
重置
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user