Improve canvas toolbar and image generation flow
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
import { FileText, ImageIcon, X } from "lucide-react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
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 { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasNodeInspector } from "@/components/canvas/canvas-node-inspector";
|
||||
@@ -98,6 +101,40 @@ type StoredCanvasJob = {
|
||||
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 }) {
|
||||
const router = useRouter();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -127,6 +164,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
const [showLibrary, setShowLibrary] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = 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 [isImporting, setIsImporting] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
@@ -139,6 +181,17 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
projectRef.current = project;
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
canvasSettingsStorageKey,
|
||||
JSON.stringify(canvasSettings),
|
||||
);
|
||||
} catch {
|
||||
// Local persistence is a convenience; the canvas should keep working.
|
||||
}
|
||||
}, [canvasSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -923,31 +976,35 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
setIsGenerating(true);
|
||||
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 requestStartedAt = getNowMs();
|
||||
|
||||
setJob(null);
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "x-client-request-id": clientRequestId },
|
||||
@@ -1213,6 +1270,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
if (event.key === "Escape") {
|
||||
setShowShortcutsDialog(false);
|
||||
setShowImportDialog(false);
|
||||
setShowSettingsDialog(false);
|
||||
setActiveConnection(null);
|
||||
setSelectedConnectionId(null);
|
||||
setSelectionBox(null);
|
||||
@@ -1275,6 +1333,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
canDeleteSelection={canDeleteSelection}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
hideDockLabels={canvasSettings.hideToolbarText}
|
||||
isGenerating={isGenerating}
|
||||
isSaving={isSaving}
|
||||
scale={viewport.k}
|
||||
@@ -1296,6 +1355,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })}
|
||||
onToggleLibrary={() => setShowLibrary((current) => !current)}
|
||||
onToggleSelectionMode={() => setSelectionMode((current) => !current)}
|
||||
onOpenSettings={() => setShowSettingsDialog(true)}
|
||||
onShowShortcuts={() => setShowShortcutsDialog(true)}
|
||||
onCopyJson={() => void copyCanvasJson()}
|
||||
onImportJson={() => setShowImportDialog(true)}
|
||||
@@ -1455,6 +1515,14 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
|
||||
open={showShortcutsDialog}
|
||||
onClose={() => setShowShortcutsDialog(false)}
|
||||
/>
|
||||
<CanvasSettingsDialog
|
||||
activeTab={settingsTab}
|
||||
open={showSettingsDialog}
|
||||
settings={canvasSettings}
|
||||
onChange={setCanvasSettings}
|
||||
onClose={() => setShowSettingsDialog(false)}
|
||||
onTabChange={setSettingsTab}
|
||||
/>
|
||||
</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) {
|
||||
return key === "+" || key === "/";
|
||||
}
|
||||
|
||||
async function imageUrlToFile(url: string, filename: string) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取图片失败:HTTP ${response.status}`);
|
||||
async function imageUrlToFile(url: string, filename: string, filePath?: string) {
|
||||
const serverResponse = await fetch("/api/canvas/image-file", {
|
||||
method: "POST",
|
||||
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();
|
||||
return new File([blob], filename, {
|
||||
type: blob.type || "image/png",
|
||||
@@ -1798,7 +2054,7 @@ function inferConnectionType(
|
||||
const to = nodes.find((node) => node.id === toNodeId);
|
||||
if (to?.type === "config") return "config";
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Minus,
|
||||
Plus,
|
||||
Redo2,
|
||||
Settings,
|
||||
Sparkles,
|
||||
SquareDashedMousePointer,
|
||||
Trash2,
|
||||
@@ -36,6 +37,7 @@ type CanvasToolbarProps = {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
canDeleteSelection: boolean;
|
||||
hideDockLabels: boolean;
|
||||
onBack: () => void;
|
||||
onAddNode: (type: CanvasNodeType) => void;
|
||||
onUndo: () => void;
|
||||
@@ -53,6 +55,7 @@ type CanvasToolbarProps = {
|
||||
onToggleLibrary: () => void;
|
||||
onToggleSelectionMode: () => void;
|
||||
onShowShortcuts: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export function CanvasToolbar({
|
||||
@@ -66,6 +69,7 @@ export function CanvasToolbar({
|
||||
canUndo,
|
||||
canRedo,
|
||||
canDeleteSelection,
|
||||
hideDockLabels,
|
||||
onBack,
|
||||
onAddNode,
|
||||
onUndo,
|
||||
@@ -83,35 +87,44 @@ export function CanvasToolbar({
|
||||
onToggleLibrary,
|
||||
onToggleSelectionMode,
|
||||
onShowShortcuts,
|
||||
onOpenSettings,
|
||||
}: CanvasToolbarProps) {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
>
|
||||
<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
|
||||
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}
|
||||
title="返回画布列表"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
<Menu className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-950">{title}</div>
|
||||
<div className="mt-0.5 text-[11px] tracking-[0.18em] text-zinc-400 uppercase">
|
||||
<div className="truncate text-[13px] font-semibold text-zinc-950">{title}</div>
|
||||
<div className="mt-0.5 text-[10px] tracking-[0.16em] text-zinc-400 uppercase">
|
||||
无限画布
|
||||
</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} />
|
||||
<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}
|
||||
title="快捷键"
|
||||
>
|
||||
@@ -119,7 +132,7 @@ export function CanvasToolbar({
|
||||
</button>
|
||||
<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"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
@@ -131,7 +144,7 @@ export function CanvasToolbar({
|
||||
</button>
|
||||
<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"
|
||||
? "bg-zinc-100 text-zinc-950"
|
||||
: "text-zinc-600 hover:bg-zinc-100"
|
||||
@@ -142,7 +155,7 @@ export function CanvasToolbar({
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
</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}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
@@ -153,13 +166,13 @@ export function CanvasToolbar({
|
||||
</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
|
||||
>
|
||||
<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
|
||||
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}
|
||||
title="重置视图"
|
||||
>
|
||||
@@ -167,18 +180,18 @@ export function CanvasToolbar({
|
||||
</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"
|
||||
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
|
||||
onClick={onZoomOut}
|
||||
title="缩小"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</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)}%
|
||||
</div>
|
||||
<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}
|
||||
title="放大"
|
||||
>
|
||||
@@ -188,12 +201,13 @@ export function CanvasToolbar({
|
||||
</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
|
||||
>
|
||||
<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
|
||||
active={selectionMode}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={
|
||||
selectionMode ? (
|
||||
<SquareDashedMousePointer className="size-4" />
|
||||
@@ -206,50 +220,59 @@ export function CanvasToolbar({
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canUndo}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Undo2 className="size-4" />}
|
||||
label="撤回"
|
||||
onClick={onUndo}
|
||||
/>
|
||||
<DockButton
|
||||
disabled={!canRedo}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Redo2 className="size-4" />}
|
||||
label="重做"
|
||||
onClick={onRedo}
|
||||
/>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Type className="size-4" />}
|
||||
label="文本"
|
||||
onClick={() => onAddNode("prompt")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
label="图片"
|
||||
onClick={() => onAddNode("image")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Sparkles className="size-4" />}
|
||||
label="配置"
|
||||
onClick={() => onAddNode("config")}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Upload className="size-4" />}
|
||||
label="上传"
|
||||
onClick={onUploadMaterial}
|
||||
/>
|
||||
<DockButton
|
||||
active={showLibrary}
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<FolderOpen className="size-4" />}
|
||||
label="素材库"
|
||||
onClick={onToggleLibrary}
|
||||
/>
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Copy className="size-4" />}
|
||||
label="复制JSON"
|
||||
onClick={onCopyJson}
|
||||
/>
|
||||
<DockButton
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<ClipboardPaste className="size-4" />}
|
||||
label="导入JSON"
|
||||
onClick={onImportJson}
|
||||
@@ -259,6 +282,7 @@ export function CanvasToolbar({
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
danger
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Trash2 className="size-4" />}
|
||||
label="删除"
|
||||
onClick={onDeleteSelection}
|
||||
@@ -268,6 +292,7 @@ export function CanvasToolbar({
|
||||
<DockDivider />
|
||||
<DockButton
|
||||
danger
|
||||
hideLabel={hideDockLabels}
|
||||
icon={<Eraser className="size-4" />}
|
||||
label="清空"
|
||||
onClick={onClearCanvas}
|
||||
@@ -308,6 +333,7 @@ function DockButton({
|
||||
active = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
hideLabel = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
@@ -315,12 +341,13 @@ function DockButton({
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
hideLabel?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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
|
||||
? active
|
||||
? "bg-red-50 text-red-600"
|
||||
@@ -334,11 +361,19 @@ function DockButton({
|
||||
title={label}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
@@ -79,19 +79,16 @@ export function resolveCanvasGenerationInput(
|
||||
const configNode =
|
||||
scopedNodes.find((node) => node.type === "config") ??
|
||||
nodes.find((node) => node.type === "config");
|
||||
const imageNodes = collectImageNodes(
|
||||
const imageNodes = collectReferencedImageNodes(
|
||||
scopedNodes,
|
||||
resolvedMentions?.referencedNodeIds ?? [],
|
||||
).filter((node) => node.id !== promptNode.id);
|
||||
const fallbackImageNodes = imageNodes.length
|
||||
? imageNodes
|
||||
: collectImageNodes(nodes, []).filter((node) => node.id !== promptNode.id);
|
||||
|
||||
return {
|
||||
promptNode,
|
||||
configNode,
|
||||
imageNode: fallbackImageNodes[0],
|
||||
imageNodes: fallbackImageNodes.slice(0, 16),
|
||||
imageNode: imageNodes[0],
|
||||
imageNodes: imageNodes.slice(0, 16),
|
||||
prompt,
|
||||
config: {
|
||||
...defaultCanvasConfig,
|
||||
@@ -121,16 +118,13 @@ function formatConnectedPrompts(
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function collectImageNodes(nodes: CanvasNode[], preferredNodeIds: string[]) {
|
||||
const preferred = preferredNodeIds
|
||||
function collectReferencedImageNodes(
|
||||
nodes: CanvasNode[],
|
||||
referencedNodeIds: string[],
|
||||
) {
|
||||
return referencedNodeIds
|
||||
.map((id) => nodes.find((node) => node.id === id))
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user