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";
}
+267 -11
View File
@@ -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,6 +976,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
setIsGenerating(true);
await saveProject(projectWithLoadingNode);
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);
@@ -933,21 +991,20 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) {
formData.append("preserveIdentity", String(resolved.config.preserveIdentity));
for (const [index, imageNode] of resolved.imageNodes.entries()) {
const imageMeta = imageNode.metadata as { imageUrl?: string };
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 clientRequestId = createClientRequestId();
const requestStartedAt = getNowMs();
setJob(null);
try {
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;
}
+57 -22
View File
@@ -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" />;
}
+8 -14
View File
@@ -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 {