mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-02 23:11:14 +08:00
feat(plugin-sdk): add TypeScript SDK for Infinite Canvas plugins with automatic JSX and build support
This commit is contained in:
@@ -39,7 +39,7 @@ export async function exportAssets(assets: Asset[]) {
|
||||
|
||||
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
|
||||
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||
saveAs(zip, "我的素材.zip");
|
||||
saveAs(zip, "我的资产.zip");
|
||||
}
|
||||
|
||||
export async function readAssetPackage(file: File) {
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function AssetsPage() {
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
}
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
message.success(editingAsset ? "资产已更新" : "资产已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function AssetsPage() {
|
||||
|
||||
const exportAllAssets = async () => {
|
||||
if (!validAssets.length) {
|
||||
message.warning("暂无素材可导出");
|
||||
message.warning("暂无资产可导出");
|
||||
return;
|
||||
}
|
||||
await exportAssets(validAssets);
|
||||
@@ -170,9 +170,9 @@ export default function AssetsPage() {
|
||||
delete payload.updatedAt;
|
||||
addAsset(payload as Parameters<typeof addAsset>[0]);
|
||||
});
|
||||
message.success(`已导入 ${importedAssets.length} 个素材`);
|
||||
message.success(`已导入 ${importedAssets.length} 个资产`);
|
||||
} catch {
|
||||
message.error("导入失败,请选择有效的素材压缩包");
|
||||
message.error("导入失败,请选择有效的资产压缩包");
|
||||
} finally {
|
||||
if (assetInputRef.current) assetInputRef.current.value = "";
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export default function AssetsPage() {
|
||||
const confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
message.success("资产已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function AssetsPage() {
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的素材</h1>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的资产</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
||||
</div>
|
||||
|
||||
@@ -239,21 +239,21 @@ export default function AssetsPage() {
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => void exportAllAssets()}
|
||||
>
|
||||
导出素材
|
||||
导出资产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={() => assetInputRef.current?.click()}
|
||||
>
|
||||
导入素材
|
||||
导入资产
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新增素材
|
||||
新增资产
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@ export default function AssetsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到资产" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
@@ -285,7 +285,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Modal title={editingAsset ? "编辑资产" : "新增资产"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
@@ -298,7 +298,7 @@ export default function AssetsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||
<Input size="large" placeholder="给资产起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
@@ -321,7 +321,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
{formKind === "text" ? (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本资产" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="图片内容" required>
|
||||
@@ -352,7 +352,7 @@ export default function AssetsPage() {
|
||||
)}
|
||||
<div className="p-4">
|
||||
<Typography.Text strong ellipsis className="block">
|
||||
{title || "未命名素材"}
|
||||
{title || "未命名资产"}
|
||||
</Typography.Text>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{tags.length ? (
|
||||
@@ -395,8 +395,8 @@ export default function AssetsPage() {
|
||||
|
||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
<Modal title="删除资产" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的资产中移除。
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
@@ -474,7 +474,7 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
||||
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
||||
return (
|
||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
<Drawer title="资产详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
<div className="space-y-5">
|
||||
{cover ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Puzzle, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
|
||||
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
@@ -38,6 +38,7 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { CanvasSidePanel } from "@/components/canvas/canvas-side-panel";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
@@ -1049,6 +1050,21 @@ function InfiniteCanvasPage() {
|
||||
setContextMenu(null);
|
||||
}, [size.height, size.width]);
|
||||
|
||||
const focusNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node) return;
|
||||
const worldX = node.position.x + node.width / 2;
|
||||
const worldY = node.position.y + node.height / 2;
|
||||
const k = Math.min(Math.max(Math.min((size.width * 0.6) / node.width, (size.height * 0.6) / node.height), 0.05), 1.5);
|
||||
setViewport({ x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k });
|
||||
setSelectedNodeIds(new Set([nodeId]));
|
||||
setSelectedConnectionId(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[size.height, size.width],
|
||||
);
|
||||
|
||||
const setZoomScale = useCallback(
|
||||
(scale: number) => {
|
||||
const nextScale = Math.min(Math.max(scale, 0.05), 5);
|
||||
@@ -1651,13 +1667,13 @@ function InfiniteCanvasPage() {
|
||||
const content = node.metadata?.content?.trim();
|
||||
if (!content) return message.error("没有可保存的文本");
|
||||
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
||||
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
return;
|
||||
}
|
||||
if (!node.metadata?.content) return message.error("没有可保存的图片");
|
||||
@@ -1678,7 +1694,7 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
},
|
||||
[addAsset, message],
|
||||
);
|
||||
@@ -2555,6 +2571,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
||||
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onInsertAsset={handleAssetInsert} />
|
||||
<section className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<CanvasTopBar
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
@@ -2796,9 +2813,6 @@ function InfiniteCanvasPage() {
|
||||
onDeselect={deselectCanvas}
|
||||
onBackgroundModeChange={setBackgroundMode}
|
||||
onShowImageInfoChange={setShowImageInfo}
|
||||
onOpenMyAssets={() => {
|
||||
setAssetPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} onViewportChange={setViewport} /> : null}
|
||||
@@ -2957,8 +2971,7 @@ function CanvasTopBar({
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "plugins", icon: <Puzzle className="size-4" />, label: "节点插件", onClick: onOpenPlugins },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
@@ -3002,6 +3015,7 @@ function CanvasTopBar({
|
||||
<UserStatusActions
|
||||
variant="canvas"
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
/>
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function ImagePage() {
|
||||
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
|
||||
metadata: { source: "image-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
@@ -242,7 +242,7 @@ export default function ImagePage() {
|
||||
const stored = await uploadImage(payload.dataUrl);
|
||||
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
||||
} else {
|
||||
message.warning("生图工作台只能使用文本或图片素材");
|
||||
message.warning("生图工作台只能使用文本或图片资产");
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
@@ -388,7 +388,7 @@ export default function ImagePage() {
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
查看我的资产
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,9 +560,9 @@ function ResultImageCard({
|
||||
<span>{formatDuration(image.durationMs)}</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-3 gap-2">
|
||||
<Tooltip title="添加到素材">
|
||||
<Tooltip title="添加到资产">
|
||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||
添加到素材
|
||||
添加到资产
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="加入参考图">
|
||||
|
||||
@@ -32,7 +32,7 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
||||
</Button>
|
||||
{onSaveAsset ? (
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||
加入我的素材
|
||||
加入我的资产
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PromptsPage() {
|
||||
|
||||
const savePromptAsset = (item: Prompt) => {
|
||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
@@ -106,7 +106,7 @@ export default function PromptsPage() {
|
||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||
extraAction={
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||
加入我的素材
|
||||
加入我的资产
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function VideoPage() {
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
const selectedFiles = Array.from(files || []);
|
||||
const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file));
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考素材,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
|
||||
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
|
||||
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
|
||||
@@ -244,7 +244,7 @@ export default function VideoPage() {
|
||||
data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType },
|
||||
metadata: { source: "video-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
message.success("已加入我的资产");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
@@ -394,7 +394,7 @@ export default function VideoPage() {
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
查看我的资产
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,7 +573,7 @@ function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedV
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
|
||||
添加到素材
|
||||
添加到资产
|
||||
</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
|
||||
下载
|
||||
|
||||
Reference in New Issue
Block a user