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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user