Improve canvas toolbar and image generation flow

This commit is contained in:
2026-06-23 12:22:27 +08:00
parent 4ff348e808
commit 0ec26843d2
4 changed files with 432 additions and 61 deletions
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import {
getGeneratedImagePathFromUrl,
readPersistedImageFile,
} from "@/lib/server/storage/image-file-storage";
export const runtime = "nodejs";
export async function POST(request: Request) {
const payload = (await request.json().catch(() => ({}))) as {
filePath?: string;
imageUrl?: string;
};
const imageUrl = payload.imageUrl?.trim();
const filePath = resolveReadableImagePath(imageUrl, payload.filePath?.trim());
if (filePath) {
const buffer = await readPersistedImageFile(filePath);
if (!buffer) {
return NextResponse.json({ error: "图片文件不存在" }, { status: 404 });
}
return createImageResponse(buffer, getImageContentType(filePath));
}
if (!imageUrl) {
return NextResponse.json({ error: "图片地址不可读取" }, { status: 400 });
}
const remoteImage = await readRemoteImage(imageUrl);
if (!remoteImage) {
return NextResponse.json({ error: "远程图片不可访问" }, { status: 502 });
}
return createImageResponse(remoteImage.buffer, remoteImage.contentType);
}
function createImageResponse(buffer: Buffer, contentType: string) {
return new Response(buffer, {
headers: {
"Cache-Control": "no-store",
"Content-Type": contentType,
},
});
}
function resolveReadableImagePath(imageUrl?: string, filePath?: string) {
if (filePath?.startsWith("s3://")) return filePath;
if (imageUrl) return getGeneratedImagePathFromUrl(imageUrl);
return null;
}
async function readRemoteImage(imageUrl: string) {
if (!isAllowedRemoteImageUrl(imageUrl)) return null;
const response = await fetch(imageUrl, { cache: "no-store" });
if (!response.ok) return null;
const contentType = response.headers.get("content-type") || "image/png";
if (!contentType.startsWith("image/")) return null;
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType,
};
}
function isAllowedRemoteImageUrl(imageUrl: string) {
try {
const url = new URL(imageUrl);
return (
url.protocol === "https:" &&
url.hostname === "file.hanhan.ltd" &&
url.pathname.startsWith("/imagegen-tools/generated/")
);
} catch {
return false;
}
}
function getImageContentType(filePath: string) {
const extension = filePath.split(".").pop()?.toLowerCase();
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
if (extension === "webp") return "image/webp";
return "image/png";
}
+281 -25
View File
@@ -9,6 +9,9 @@ import type {
import { FileText, ImageIcon, X } from "lucide-react"; import { FileText, ImageIcon, X } from "lucide-react";
import { toast, Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { CanvasConnections } from "@/components/canvas/canvas-connections"; import { CanvasConnections } from "@/components/canvas/canvas-connections";
import { CanvasNode } from "@/components/canvas/canvas-node"; import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasNodeInspector } from "@/components/canvas/canvas-node-inspector"; import { CanvasNodeInspector } from "@/components/canvas/canvas-node-inspector";
@@ -98,6 +101,40 @@ type StoredCanvasJob = {
requestStartedAt: number; requestStartedAt: number;
}; };
type CanvasSettingsTab = "model" | "interface";
type CanvasSettings = {
hideToolbarText: boolean;
modelName: string;
baseUrl: string;
apiKeySource: string;
};
const canvasSettingsStorageKey = "imagegen:canvas:settings";
const defaultCanvasSettings: CanvasSettings = {
hideToolbarText: false,
modelName: "gpt-image-2-2k",
baseUrl: "OPENAI_BASE_URL",
apiKeySource: "OPENAI_API_KEY",
};
function getInitialCanvasSettings() {
if (typeof window === "undefined") return defaultCanvasSettings;
try {
const raw = window.localStorage.getItem(canvasSettingsStorageKey);
if (!raw) return defaultCanvasSettings;
const stored = JSON.parse(raw) as Partial<CanvasSettings>;
return {
...defaultCanvasSettings,
...stored,
hideToolbarText: Boolean(stored.hideToolbarText),
};
} catch {
return defaultCanvasSettings;
}
}
export function CanvasEditorClient({ projectId }: { projectId: string }) { export function CanvasEditorClient({ projectId }: { projectId: string }) {
const router = useRouter(); const router = useRouter();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -127,6 +164,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
const [showLibrary, setShowLibrary] = useState(false); const [showLibrary, setShowLibrary] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [showShortcutsDialog, setShowShortcutsDialog] = useState(false); const [showShortcutsDialog, setShowShortcutsDialog] = useState(false);
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const [settingsTab, setSettingsTab] = useState<CanvasSettingsTab>("model");
const [canvasSettings, setCanvasSettings] = useState<CanvasSettings>(
getInitialCanvasSettings,
);
const [importJson, setImportJson] = useState(""); const [importJson, setImportJson] = useState("");
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
@@ -139,6 +181,17 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
projectRef.current = project; projectRef.current = project;
}, [project]); }, [project]);
useEffect(() => {
try {
window.localStorage.setItem(
canvasSettingsStorageKey,
JSON.stringify(canvasSettings),
);
} catch {
// Local persistence is a convenience; the canvas should keep working.
}
}, [canvasSettings]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -923,31 +976,35 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
setIsGenerating(true); setIsGenerating(true);
await saveProject(projectWithLoadingNode); await saveProject(projectWithLoadingNode);
const formData = new FormData();
formData.append("mode", resolved.imageNodes.length ? "edit" : "generate");
formData.append("prompt", resolved.prompt);
formData.append("model", resolved.config.model);
formData.append("size", resolved.config.size);
formData.append("quality", resolved.config.quality);
formData.append("outputFormat", resolved.config.outputFormat);
formData.append("preserveIdentity", String(resolved.config.preserveIdentity));
for (const [index, imageNode] of resolved.imageNodes.entries()) {
const imageMeta = imageNode.metadata as { imageUrl?: string };
if (imageMeta.imageUrl) {
const file = await imageUrlToFile(
imageMeta.imageUrl,
`canvas-reference-${index + 1}.png`,
);
formData.append("image", file);
}
}
const clientRequestId = createClientRequestId(); const clientRequestId = createClientRequestId();
const requestStartedAt = getNowMs(); const requestStartedAt = getNowMs();
setJob(null); setJob(null);
try { try {
const formData = new FormData();
formData.append("mode", resolved.imageNodes.length ? "edit" : "generate");
formData.append("prompt", resolved.prompt);
formData.append("model", resolved.config.model);
formData.append("size", resolved.config.size);
formData.append("quality", resolved.config.quality);
formData.append("outputFormat", resolved.config.outputFormat);
formData.append("preserveIdentity", String(resolved.config.preserveIdentity));
for (const [index, imageNode] of resolved.imageNodes.entries()) {
const imageMeta = imageNode.metadata as {
filePath?: string;
imageUrl?: string;
};
if (imageMeta.imageUrl) {
const file = await imageUrlToFile(
imageMeta.imageUrl,
`canvas-reference-${index + 1}.png`,
imageMeta.filePath,
);
formData.append("image", file);
}
}
const response = await fetch("/api/images", { const response = await fetch("/api/images", {
method: "POST", method: "POST",
headers: { "x-client-request-id": clientRequestId }, headers: { "x-client-request-id": clientRequestId },
@@ -1213,6 +1270,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
if (event.key === "Escape") { if (event.key === "Escape") {
setShowShortcutsDialog(false); setShowShortcutsDialog(false);
setShowImportDialog(false); setShowImportDialog(false);
setShowSettingsDialog(false);
setActiveConnection(null); setActiveConnection(null);
setSelectedConnectionId(null); setSelectedConnectionId(null);
setSelectionBox(null); setSelectionBox(null);
@@ -1275,6 +1333,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
canDeleteSelection={canDeleteSelection} canDeleteSelection={canDeleteSelection}
canRedo={historyAvailability.canRedo} canRedo={historyAvailability.canRedo}
canUndo={historyAvailability.canUndo} canUndo={historyAvailability.canUndo}
hideDockLabels={canvasSettings.hideToolbarText}
isGenerating={isGenerating} isGenerating={isGenerating}
isSaving={isSaving} isSaving={isSaving}
scale={viewport.k} scale={viewport.k}
@@ -1296,6 +1355,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })} onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })}
onToggleLibrary={() => setShowLibrary((current) => !current)} onToggleLibrary={() => setShowLibrary((current) => !current)}
onToggleSelectionMode={() => setSelectionMode((current) => !current)} onToggleSelectionMode={() => setSelectionMode((current) => !current)}
onOpenSettings={() => setShowSettingsDialog(true)}
onShowShortcuts={() => setShowShortcutsDialog(true)} onShowShortcuts={() => setShowShortcutsDialog(true)}
onCopyJson={() => void copyCanvasJson()} onCopyJson={() => void copyCanvasJson()}
onImportJson={() => setShowImportDialog(true)} onImportJson={() => setShowImportDialog(true)}
@@ -1455,6 +1515,14 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
open={showShortcutsDialog} open={showShortcutsDialog}
onClose={() => setShowShortcutsDialog(false)} onClose={() => setShowShortcutsDialog(false)}
/> />
<CanvasSettingsDialog
activeTab={settingsTab}
open={showSettingsDialog}
settings={canvasSettings}
onChange={setCanvasSettings}
onClose={() => setShowSettingsDialog(false)}
onTabChange={setSettingsTab}
/>
</main> </main>
); );
} }
@@ -1730,15 +1798,203 @@ function ShortcutDialog({
); );
} }
const SETTINGS_TABS = [
{
id: "model",
title: "大模型配置",
description: "模型、接口和密钥来源",
},
{
id: "interface",
title: "界面设置",
description: "画布操作体验",
},
] satisfies Array<{
id: CanvasSettingsTab;
title: string;
description: string;
}>;
function CanvasSettingsDialog({
activeTab,
open,
settings,
onChange,
onClose,
onTabChange,
}: {
activeTab: CanvasSettingsTab;
open: boolean;
settings: CanvasSettings;
onChange: (settings: CanvasSettings) => void;
onClose: () => void;
onTabChange: (tab: CanvasSettingsTab) => void;
}) {
if (!open) return null;
const patchSettings = (patch: Partial<CanvasSettings>) => {
onChange({ ...settings, ...patch });
};
return (
<div
className="absolute inset-0 z-[130] flex items-center justify-center bg-zinc-950/24 p-4 backdrop-blur-sm"
data-canvas-ui
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="grid max-h-[86vh] w-full max-w-3xl grid-cols-[210px_minmax(0,1fr)] overflow-hidden rounded-lg bg-white shadow-[0_24px_80px_rgba(24,24,27,.24)] ring-1 ring-zinc-200 max-md:grid-cols-1">
<aside className="border-r border-zinc-200 bg-zinc-50 p-3 max-md:border-b max-md:border-r-0">
<div className="flex items-center justify-between px-2 py-2">
<div>
<h2 className="text-base font-semibold text-zinc-950"></h2>
<p className="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
className="hidden size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950 max-md:flex"
onClick={onClose}
title="关闭"
>
<X className="size-4" />
</button>
</div>
<nav className="mt-3 grid gap-1">
{SETTINGS_TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`rounded-md px-3 py-2.5 text-left transition ${
activeTab === tab.id
? "bg-white text-zinc-950 shadow-sm ring-1 ring-zinc-200"
: "text-zinc-600 hover:bg-white hover:text-zinc-950"
}`}
onClick={() => onTabChange(tab.id)}
>
<span className="block text-sm font-medium">{tab.title}</span>
<span className="mt-0.5 block text-xs text-zinc-400">
{tab.description}
</span>
</button>
))}
</nav>
</aside>
<section className="min-h-0 overflow-y-auto p-6">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-zinc-950">
{activeTab === "model" ? "大模型配置" : "界面设置"}
</h3>
<p className="mt-1 text-sm text-zinc-500">
{activeTab === "model"
? "当前为本地界面配置记录,实际请求仍读取服务端环境变量。"
: "调整画布工具栏在不同窗口尺寸下的展示方式。"}
</p>
</div>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950 max-md:hidden"
onClick={onClose}
title="关闭"
>
<X className="size-4" />
</button>
</div>
{activeTab === "model" ? (
<div className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="canvas-model-name"></Label>
<Input
id="canvas-model-name"
value={settings.modelName}
onChange={(event) =>
patchSettings({ modelName: event.target.value })
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="canvas-base-url"></Label>
<Input
id="canvas-base-url"
value={settings.baseUrl}
onChange={(event) =>
patchSettings({ baseUrl: event.target.value })
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="canvas-api-key-source"></Label>
<Input
id="canvas-api-key-source"
value={settings.apiKeySource}
onChange={(event) =>
patchSettings({ apiKeySource: event.target.value })
}
/>
</div>
<div className="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-500">
使 .env OPENAI_API_KEY
OPENAI_BASE_URL
</div>
</div>
) : (
<div className="grid gap-4">
<div className="flex items-center justify-between gap-5 rounded-md border border-zinc-200 bg-white px-4 py-3">
<div className="min-w-0">
<div className="text-sm font-medium text-zinc-950">
</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
</div>
</div>
<Switch
checked={settings.hideToolbarText}
onCheckedChange={(checked) =>
patchSettings({ hideToolbarText: checked })
}
/>
</div>
</div>
)}
</section>
</div>
</div>
);
}
function isShortcutSeparator(key: string) { function isShortcutSeparator(key: string) {
return key === "+" || key === "/"; return key === "+" || key === "/";
} }
async function imageUrlToFile(url: string, filename: string) { async function imageUrlToFile(url: string, filename: string, filePath?: string) {
const response = await fetch(url, { cache: "no-store" }); const serverResponse = await fetch("/api/canvas/image-file", {
if (!response.ok) { method: "POST",
throw new Error(`读取图片失败:HTTP ${response.status}`); headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath, imageUrl: url }),
});
if (serverResponse.ok) {
const blob = await serverResponse.blob();
return new File([blob], filename, {
type: blob.type || "image/png",
});
} }
const resolvedUrl = new URL(url, window.location.origin).toString();
let response: Response;
try {
response = await fetch(resolvedUrl, { cache: "no-store" });
} catch {
throw new Error(`读取参考图片失败:无法访问 ${url}`);
}
if (!response.ok) {
throw new Error(`读取参考图片失败:HTTP ${response.status} ${url}`);
}
const blob = await response.blob(); const blob = await response.blob();
return new File([blob], filename, { return new File([blob], filename, {
type: blob.type || "image/png", type: blob.type || "image/png",
@@ -1798,7 +2054,7 @@ function inferConnectionType(
const to = nodes.find((node) => node.id === toNodeId); const to = nodes.find((node) => node.id === toNodeId);
if (to?.type === "config") return "config"; if (to?.type === "config") return "config";
if (to?.type === "prompt") return from?.type === "image" ? "reference" : "prompt"; if (to?.type === "prompt") return from?.type === "image" ? "reference" : "prompt";
if (to?.type === "image") return from?.type === "config" ? "config" : "generated"; if (to?.type === "image") return "generated";
return undefined; return undefined;
} }
+57 -22
View File
@@ -14,6 +14,7 @@ import {
Minus, Minus,
Plus, Plus,
Redo2, Redo2,
Settings,
Sparkles, Sparkles,
SquareDashedMousePointer, SquareDashedMousePointer,
Trash2, Trash2,
@@ -36,6 +37,7 @@ type CanvasToolbarProps = {
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
canDeleteSelection: boolean; canDeleteSelection: boolean;
hideDockLabels: boolean;
onBack: () => void; onBack: () => void;
onAddNode: (type: CanvasNodeType) => void; onAddNode: (type: CanvasNodeType) => void;
onUndo: () => void; onUndo: () => void;
@@ -53,6 +55,7 @@ type CanvasToolbarProps = {
onToggleLibrary: () => void; onToggleLibrary: () => void;
onToggleSelectionMode: () => void; onToggleSelectionMode: () => void;
onShowShortcuts: () => void; onShowShortcuts: () => void;
onOpenSettings: () => void;
}; };
export function CanvasToolbar({ export function CanvasToolbar({
@@ -66,6 +69,7 @@ export function CanvasToolbar({
canUndo, canUndo,
canRedo, canRedo,
canDeleteSelection, canDeleteSelection,
hideDockLabels,
onBack, onBack,
onAddNode, onAddNode,
onUndo, onUndo,
@@ -83,35 +87,44 @@ export function CanvasToolbar({
onToggleLibrary, onToggleLibrary,
onToggleSelectionMode, onToggleSelectionMode,
onShowShortcuts, onShowShortcuts,
onOpenSettings,
}: CanvasToolbarProps) { }: CanvasToolbarProps) {
return ( return (
<> <>
<div <div
className="pointer-events-none absolute inset-x-0 top-0 z-[90] flex items-start justify-between p-5" className="pointer-events-none absolute inset-x-0 top-0 z-[90] flex items-start justify-between p-4"
data-canvas-ui data-canvas-ui
> >
<div className="pointer-events-auto flex items-center gap-3 rounded-2xl bg-white/92 px-4 py-3 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl"> <div className="pointer-events-auto flex items-center gap-2.5 rounded-[13px] bg-white/92 px-3 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<button <button
type="button" type="button"
className="flex size-9 items-center justify-center rounded-full text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950" className="flex size-8 items-center justify-center rounded-full text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onBack} onClick={onBack}
title="返回画布列表" title="返回画布列表"
> >
<Menu className="size-5" /> <Menu className="size-4" />
</button> </button>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-950">{title}</div> <div className="truncate text-[13px] font-semibold text-zinc-950">{title}</div>
<div className="mt-0.5 text-[11px] tracking-[0.18em] text-zinc-400 uppercase"> <div className="mt-0.5 text-[10px] tracking-[0.16em] text-zinc-400 uppercase">
</div> </div>
</div> </div>
</div> </div>
<div className="pointer-events-auto flex items-center gap-2 rounded-2xl bg-white/92 px-3 py-2.5 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl"> <div className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<StatusPill isGenerating={isGenerating} isSaving={isSaving} /> <StatusPill isGenerating={isGenerating} isSaving={isSaving} />
<button <button
type="button" type="button"
className="flex size-9 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950" className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onOpenSettings}
title="设置"
>
<Settings className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onShowShortcuts} onClick={onShowShortcuts}
title="快捷键" title="快捷键"
> >
@@ -119,7 +132,7 @@ export function CanvasToolbar({
</button> </button>
<button <button
type="button" type="button"
className={`flex size-9 items-center justify-center rounded-full transition ${ className={`flex size-8 items-center justify-center rounded-full transition ${
backgroundMode === "lines" backgroundMode === "lines"
? "bg-zinc-100 text-zinc-950" ? "bg-zinc-100 text-zinc-950"
: "text-zinc-600 hover:bg-zinc-100" : "text-zinc-600 hover:bg-zinc-100"
@@ -131,7 +144,7 @@ export function CanvasToolbar({
</button> </button>
<button <button
type="button" type="button"
className={`flex size-9 items-center justify-center rounded-full transition ${ className={`flex size-8 items-center justify-center rounded-full transition ${
backgroundMode === "dots" backgroundMode === "dots"
? "bg-zinc-100 text-zinc-950" ? "bg-zinc-100 text-zinc-950"
: "text-zinc-600 hover:bg-zinc-100" : "text-zinc-600 hover:bg-zinc-100"
@@ -142,7 +155,7 @@ export function CanvasToolbar({
<SquareDashedMousePointer className="size-4" /> <SquareDashedMousePointer className="size-4" />
</button> </button>
<Button <Button
className="rounded-full bg-zinc-950 px-4 shadow-none hover:bg-zinc-800" className="h-8 rounded-full bg-zinc-950 px-3 text-xs shadow-none hover:bg-zinc-800"
disabled={isGenerating} disabled={isGenerating}
onClick={onGenerate} onClick={onGenerate}
> >
@@ -153,13 +166,13 @@ export function CanvasToolbar({
</div> </div>
<div <div
className="pointer-events-none absolute bottom-5 left-5 z-[90]" className="pointer-events-none absolute bottom-4 left-4 z-[90]"
data-canvas-ui data-canvas-ui
> >
<div className="pointer-events-auto flex items-center gap-2 rounded-2xl bg-white/92 px-3 py-2 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl"> <div className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<button <button
type="button" type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950" className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onResetView} onClick={onResetView}
title="重置视图" title="重置视图"
> >
@@ -167,18 +180,18 @@ export function CanvasToolbar({
</button> </button>
<button <button
type="button" type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950" className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onZoomOut} onClick={onZoomOut}
title="缩小" title="缩小"
> >
<Minus className="size-4" /> <Minus className="size-4" />
</button> </button>
<div className="min-w-14 text-center text-sm font-medium text-zinc-700"> <div className="min-w-12 text-center text-xs font-medium text-zinc-700">
{Math.round(scale * 100)}% {Math.round(scale * 100)}%
</div> </div>
<button <button
type="button" type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950" className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onZoomIn} onClick={onZoomIn}
title="放大" title="放大"
> >
@@ -188,12 +201,13 @@ export function CanvasToolbar({
</div> </div>
<div <div
className="pointer-events-none absolute bottom-5 left-1/2 z-[90] -translate-x-1/2" className="pointer-events-none absolute bottom-4 left-1/2 z-[90] -translate-x-1/2"
data-canvas-ui data-canvas-ui
> >
<div className="pointer-events-auto flex items-center gap-1 rounded-[26px] bg-white/92 px-3 py-2 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl"> <div className="pointer-events-auto flex items-center gap-1 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<DockButton <DockButton
active={selectionMode} active={selectionMode}
hideLabel={hideDockLabels}
icon={ icon={
selectionMode ? ( selectionMode ? (
<SquareDashedMousePointer className="size-4" /> <SquareDashedMousePointer className="size-4" />
@@ -206,50 +220,59 @@ export function CanvasToolbar({
/> />
<DockButton <DockButton
disabled={!canUndo} disabled={!canUndo}
hideLabel={hideDockLabels}
icon={<Undo2 className="size-4" />} icon={<Undo2 className="size-4" />}
label="撤回" label="撤回"
onClick={onUndo} onClick={onUndo}
/> />
<DockButton <DockButton
disabled={!canRedo} disabled={!canRedo}
hideLabel={hideDockLabels}
icon={<Redo2 className="size-4" />} icon={<Redo2 className="size-4" />}
label="重做" label="重做"
onClick={onRedo} onClick={onRedo}
/> />
<DockDivider /> <DockDivider />
<DockButton <DockButton
hideLabel={hideDockLabels}
icon={<Type className="size-4" />} icon={<Type className="size-4" />}
label="文本" label="文本"
onClick={() => onAddNode("prompt")} onClick={() => onAddNode("prompt")}
/> />
<DockButton <DockButton
hideLabel={hideDockLabels}
icon={<ImageIcon className="size-4" />} icon={<ImageIcon className="size-4" />}
label="图片" label="图片"
onClick={() => onAddNode("image")} onClick={() => onAddNode("image")}
/> />
<DockButton <DockButton
hideLabel={hideDockLabels}
icon={<Sparkles className="size-4" />} icon={<Sparkles className="size-4" />}
label="配置" label="配置"
onClick={() => onAddNode("config")} onClick={() => onAddNode("config")}
/> />
<DockButton <DockButton
hideLabel={hideDockLabels}
icon={<Upload className="size-4" />} icon={<Upload className="size-4" />}
label="上传" label="上传"
onClick={onUploadMaterial} onClick={onUploadMaterial}
/> />
<DockButton <DockButton
active={showLibrary} active={showLibrary}
hideLabel={hideDockLabels}
icon={<FolderOpen className="size-4" />} icon={<FolderOpen className="size-4" />}
label="素材库" label="素材库"
onClick={onToggleLibrary} onClick={onToggleLibrary}
/> />
<DockDivider /> <DockDivider />
<DockButton <DockButton
hideLabel={hideDockLabels}
icon={<Copy className="size-4" />} icon={<Copy className="size-4" />}
label="复制JSON" label="复制JSON"
onClick={onCopyJson} onClick={onCopyJson}
/> />
<DockButton <DockButton
hideLabel={hideDockLabels}
icon={<ClipboardPaste className="size-4" />} icon={<ClipboardPaste className="size-4" />}
label="导入JSON" label="导入JSON"
onClick={onImportJson} onClick={onImportJson}
@@ -259,6 +282,7 @@ export function CanvasToolbar({
<DockDivider /> <DockDivider />
<DockButton <DockButton
danger danger
hideLabel={hideDockLabels}
icon={<Trash2 className="size-4" />} icon={<Trash2 className="size-4" />}
label="删除" label="删除"
onClick={onDeleteSelection} onClick={onDeleteSelection}
@@ -268,6 +292,7 @@ export function CanvasToolbar({
<DockDivider /> <DockDivider />
<DockButton <DockButton
danger danger
hideLabel={hideDockLabels}
icon={<Eraser className="size-4" />} icon={<Eraser className="size-4" />}
label="清空" label="清空"
onClick={onClearCanvas} onClick={onClearCanvas}
@@ -308,6 +333,7 @@ function DockButton({
active = false, active = false,
danger = false, danger = false,
disabled = false, disabled = false,
hideLabel = false,
onClick, onClick,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
@@ -315,12 +341,13 @@ function DockButton({
active?: boolean; active?: boolean;
danger?: boolean; danger?: boolean;
disabled?: boolean; disabled?: boolean;
hideLabel?: boolean;
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<button <button
type="button" type="button"
className={`flex h-11 items-center gap-2 rounded-2xl px-3 text-sm font-medium transition disabled:pointer-events-none disabled:opacity-40 ${ className={`group relative flex h-9 items-center justify-center gap-2 rounded-[10px] text-xs font-medium transition disabled:pointer-events-none disabled:opacity-40 ${hideLabel ? "w-9 px-0" : "px-2.5 max-[1100px]:w-9 max-[1100px]:px-0"} ${
danger danger
? active ? active
? "bg-red-50 text-red-600" ? "bg-red-50 text-red-600"
@@ -334,11 +361,19 @@ function DockButton({
title={label} title={label}
> >
{icon} {icon}
<span className="hidden sm:inline">{label}</span> <span className={hideLabel ? "sr-only" : "hidden min-[1101px]:inline"}>
{label}
</span>
<span
aria-hidden="true"
className={`pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-xs font-medium text-white opacity-0 shadow-lg transition group-hover:opacity-100 group-focus-visible:opacity-100 ${hideLabel ? "" : "min-[1101px]:hidden"}`}
>
{label}
</span>
</button> </button>
); );
} }
function DockDivider() { function DockDivider() {
return <div className="mx-1 h-7 w-px bg-zinc-200" />; return <div className="mx-1 h-6 w-px bg-zinc-200" />;
} }
+8 -14
View File
@@ -79,19 +79,16 @@ export function resolveCanvasGenerationInput(
const configNode = const configNode =
scopedNodes.find((node) => node.type === "config") ?? scopedNodes.find((node) => node.type === "config") ??
nodes.find((node) => node.type === "config"); nodes.find((node) => node.type === "config");
const imageNodes = collectImageNodes( const imageNodes = collectReferencedImageNodes(
scopedNodes, scopedNodes,
resolvedMentions?.referencedNodeIds ?? [], resolvedMentions?.referencedNodeIds ?? [],
).filter((node) => node.id !== promptNode.id); ).filter((node) => node.id !== promptNode.id);
const fallbackImageNodes = imageNodes.length
? imageNodes
: collectImageNodes(nodes, []).filter((node) => node.id !== promptNode.id);
return { return {
promptNode, promptNode,
configNode, configNode,
imageNode: fallbackImageNodes[0], imageNode: imageNodes[0],
imageNodes: fallbackImageNodes.slice(0, 16), imageNodes: imageNodes.slice(0, 16),
prompt, prompt,
config: { config: {
...defaultCanvasConfig, ...defaultCanvasConfig,
@@ -121,16 +118,13 @@ function formatConnectedPrompts(
.join("\n\n"); .join("\n\n");
} }
function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) { function collectReferencedImageNodes(
const preferred = preferredNodeIds nodes: CanvasNode[],
referencedNodeIds: string[],
) {
return referencedNodeIds
.map((id) => nodes.find((node) => node.id === id)) .map((id) => nodes.find((node) => node.id === id))
.filter(isImageWithUrl); .filter(isImageWithUrl);
const preferredIds = new Set(preferred.map((node) => node.id));
const remaining = nodes
.filter(isImageWithUrl)
.filter((node) => !preferredIds.has(node.id));
return [...preferred, ...remaining];
} }
function isImageWithUrl(node: CanvasNode | undefined): node is CanvasNode { function isImageWithUrl(node: CanvasNode | undefined): node is CanvasNode {