mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(plugin): implement canvas node plugin system with dynamic registration and remote installation
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
|
||||
import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export type CanvasAgentOp =
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
@@ -42,7 +42,7 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA
|
||||
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
||||
if (!op?.type) return;
|
||||
if (op.type === "add_node") {
|
||||
const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text;
|
||||
const nodeType = op.nodeType && isRegisteredNodeType(op.nodeType) ? op.nodeType : CanvasNodeType.Text;
|
||||
const spec = getNodeSpec(nodeType);
|
||||
const node: CanvasNodeData = {
|
||||
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import type { PluginStorage } from "@/types/canvas-plugin";
|
||||
|
||||
// 画布内轻量事件总线,供节点/插件互相通信
|
||||
type Handler = (payload: unknown) => void;
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
|
||||
export function emitCanvasEvent(event: string, payload?: unknown) {
|
||||
handlers.get(event)?.forEach((handler) => {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (error) {
|
||||
console.error(`[canvas-event] handler for "${event}" failed`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function onCanvasEvent(event: string, handler: Handler) {
|
||||
let set = handlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
return () => set!.delete(handler);
|
||||
}
|
||||
|
||||
// 插件私有存储,按 pluginId 命名空间隔离
|
||||
const stores = new Map<string, LocalForage>();
|
||||
|
||||
export function createPluginStorage(pluginId: string): PluginStorage {
|
||||
let store = stores.get(pluginId);
|
||||
if (!store) {
|
||||
store = localforage.createInstance({ name: "infinite-canvas-plugins", storeName: pluginId });
|
||||
stores.set(pluginId, store);
|
||||
}
|
||||
return {
|
||||
get: (key) => store!.getItem(key),
|
||||
set: async (key, value) => {
|
||||
await store!.setItem(key, value);
|
||||
},
|
||||
remove: async (key) => {
|
||||
await store!.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||
@@ -71,7 +72,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
||||
label,
|
||||
title: node.title || label,
|
||||
previewUrl: node.metadata?.content,
|
||||
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
|
||||
text: resourceText(node),
|
||||
active,
|
||||
},
|
||||
];
|
||||
@@ -89,10 +90,17 @@ function isResourceNode(node: CanvasNodeData) {
|
||||
return Boolean(resourceKind(node));
|
||||
}
|
||||
|
||||
function resourceText(node: CanvasNodeData): string | undefined {
|
||||
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt;
|
||||
const resource = getNodeDefinition(node.type)?.resource?.(node);
|
||||
return resource?.kind === "text" ? resource.text : undefined;
|
||||
}
|
||||
|
||||
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||
return null;
|
||||
// 插件节点通过 definition.resource 声明可作为输入
|
||||
return getNodeDefinition(node.type)?.resource?.(node)?.kind || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import type { CanvasNodeDefinition } from "@/types/canvas-plugin";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
const definitions = new Map<string, CanvasNodeDefinition>();
|
||||
const ownerByType = new Map<string, string>(); // type -> pluginId(内置为 "builtin")
|
||||
|
||||
// 注册表版本号,注册/卸载时自增,驱动创建菜单等 UI 重渲染
|
||||
export const useNodeRegistryVersion = create<{ version: number }>(() => ({ version: 0 }));
|
||||
function bump() {
|
||||
useNodeRegistryVersion.setState((state) => ({ version: state.version + 1 }));
|
||||
}
|
||||
|
||||
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], pluginId = "builtin") {
|
||||
defs.forEach((def) => {
|
||||
definitions.set(def.type, def);
|
||||
ownerByType.set(def.type, pluginId);
|
||||
});
|
||||
bump();
|
||||
}
|
||||
|
||||
export function unregisterPluginNodes(pluginId: string) {
|
||||
for (const [type, owner] of ownerByType) {
|
||||
if (owner !== pluginId) continue;
|
||||
definitions.delete(type);
|
||||
ownerByType.delete(type);
|
||||
}
|
||||
bump();
|
||||
}
|
||||
|
||||
export function getNodeDefinition(type: string) {
|
||||
return definitions.get(type);
|
||||
}
|
||||
|
||||
export function getNodePluginId(type: string) {
|
||||
return ownerByType.get(type) || "builtin";
|
||||
}
|
||||
|
||||
export function listNodeDefinitions() {
|
||||
return Array.from(definitions.values());
|
||||
}
|
||||
|
||||
export function isRegisteredNodeType(type: string) {
|
||||
return definitions.has(type);
|
||||
}
|
||||
|
||||
const FALLBACK_SPEC = { width: 340, height: 240, title: "节点", metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
|
||||
|
||||
// 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用
|
||||
export function getNodeSpec(type: string) {
|
||||
const def = definitions.get(type);
|
||||
if (!def) return FALLBACK_SPEC;
|
||||
return { width: def.defaultSize.width, height: def.defaultSize.height, title: def.title, metadata: def.defaultMetadata };
|
||||
}
|
||||
|
||||
export function isBuiltinNodeType(type: string) {
|
||||
return (Object.values(CanvasNodeType) as string[]).includes(type);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { registerNodeDefinitions, unregisterPluginNodes } from "@/lib/canvas/node-registry";
|
||||
import { getPluginRuntime } from "@/lib/canvas/plugin-runtime";
|
||||
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||
import type { CanvasPlugin } from "@/types/canvas-plugin";
|
||||
|
||||
const cleanups = new Map<string, () => void>();
|
||||
|
||||
// 远程插件默认导出可以是 CanvasPlugin,或接收 runtime 返回 CanvasPlugin 的工厂
|
||||
// (工厂形式用 runtime.React,无需 bundle 自带 React)
|
||||
async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
|
||||
const blob = new Blob([source], { type: "text/javascript" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown; plugin?: unknown };
|
||||
const exported = mod.default ?? mod.plugin;
|
||||
const plugin = typeof exported === "function" ? (exported as (runtime: unknown) => unknown)(getPluginRuntime()) : exported;
|
||||
assertPlugin(plugin);
|
||||
return plugin;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin {
|
||||
const value = plugin as Partial<CanvasPlugin> | null;
|
||||
if (!value || typeof value !== "object") throw new Error("插件未导出有效对象");
|
||||
if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error("插件缺少 id 或 nodes");
|
||||
}
|
||||
|
||||
export function activatePlugin(plugin: CanvasPlugin) {
|
||||
registerNodeDefinitions(plugin.nodes, plugin.id);
|
||||
const runtime = getPluginRuntime();
|
||||
const disposers: Array<() => void> = [];
|
||||
// 插件声明的样式:启用时注入,禁用/卸载时清理
|
||||
if (plugin.css) disposers.push(runtime.injectCSS(plugin.css, plugin.id));
|
||||
const cleanup = plugin.setup?.(runtime);
|
||||
if (typeof cleanup === "function") disposers.push(cleanup);
|
||||
if (disposers.length) cleanups.set(plugin.id, () => disposers.forEach((dispose) => dispose()));
|
||||
}
|
||||
|
||||
export function deactivatePlugin(pluginId: string) {
|
||||
cleanups.get(pluginId)?.();
|
||||
cleanups.delete(pluginId);
|
||||
unregisterPluginNodes(pluginId);
|
||||
}
|
||||
|
||||
async function fetchPluginSource(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`下载失败 (HTTP ${response.status})`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
|
||||
export async function installPluginFromUrl(url: string) {
|
||||
const source = await fetchPluginSource(url);
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id); // 覆盖旧版本
|
||||
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true });
|
||||
activatePlugin(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export async function updatePlugin(record: InstalledPlugin) {
|
||||
return installPluginFromUrl(record.url);
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
|
||||
usePluginStore.getState().setEnabled(record.id, enabled);
|
||||
if (!enabled) {
|
||||
deactivatePlugin(record.id);
|
||||
return;
|
||||
}
|
||||
const plugin = await evaluatePluginSource(record.source);
|
||||
activatePlugin(plugin);
|
||||
}
|
||||
|
||||
export function uninstallPlugin(id: string) {
|
||||
deactivatePlugin(id);
|
||||
usePluginStore.getState().remove(id);
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
|
||||
// 应用启动时加载已安装且启用的插件
|
||||
export async function ensurePluginsLoaded() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
await usePluginStore.persist.rehydrate();
|
||||
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
try {
|
||||
activatePlugin(await evaluatePluginSource(record.source));
|
||||
} catch (error) {
|
||||
console.error(`[plugin] 加载失败: ${record.id}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await loadDevPlugins();
|
||||
}
|
||||
|
||||
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
|
||||
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
|
||||
async function loadDevPlugins() {
|
||||
const raw = import.meta.env.VITE_DEV_PLUGINS;
|
||||
if (!raw) return;
|
||||
const urls = raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
const source = await fetchPluginSource(`${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`);
|
||||
const plugin = await evaluatePluginSource(source);
|
||||
deactivatePlugin(plugin.id);
|
||||
activatePlugin(plugin);
|
||||
console.info(`[plugin] dev 插件已加载: ${plugin.id} (${url})`);
|
||||
} catch (error) {
|
||||
console.error(`[plugin] dev 插件加载失败: ${url}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createPluginStorage, emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||
import { getNodePluginId } from "@/lib/canvas/node-registry";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
|
||||
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
|
||||
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number): CanvasNodeContext {
|
||||
const storage = createPluginStorage(getNodePluginId(node.type));
|
||||
return {
|
||||
node,
|
||||
theme,
|
||||
scale,
|
||||
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
|
||||
updateNode: (patch) => host.updateNode(node.id, patch),
|
||||
getNode: (id) => host.getNode(id),
|
||||
getNodes: () => host.getNodes(),
|
||||
getConnections: () => host.getConnections(),
|
||||
getUpstream: () => host.getUpstream(node.id),
|
||||
getDownstream: () => host.getDownstream(node.id),
|
||||
applyOps: (ops) => host.applyOps(ops),
|
||||
emit: (event, payload) => emitCanvasEvent(event, payload),
|
||||
on: (event, handler) => onCanvasEvent(event, handler),
|
||||
storage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||
import type { CanvasPluginApp } from "@/types/canvas-plugin";
|
||||
|
||||
// 插件运行时:远程插件通过它拿到宿主的 React 实例,避免多份 React 实例
|
||||
export type PluginRuntime = CanvasPluginApp & {
|
||||
React: typeof React;
|
||||
jsx: typeof React.createElement;
|
||||
Fragment: typeof React.Fragment;
|
||||
injectCSS: (css: string, key?: string) => () => void;
|
||||
};
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
// 注入插件样式:同 key 覆盖旧样式,返回移除函数
|
||||
function injectCSS(css: string, key?: string) {
|
||||
const id = key ? `canvas-plugin-style-${key}` : undefined;
|
||||
if (id) document.getElementById(id)?.remove();
|
||||
const style = document.createElement("style");
|
||||
if (id) style.id = id;
|
||||
style.dataset.canvasPluginStyle = "true";
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
||||
export function getPluginRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
runtime = {
|
||||
React,
|
||||
jsx: React.createElement,
|
||||
Fragment: React.Fragment,
|
||||
injectCSS,
|
||||
version: typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev",
|
||||
emit: emitCanvasEvent,
|
||||
on: onCanvasEvent,
|
||||
};
|
||||
(window as unknown as { InfiniteCanvasRuntime?: PluginRuntime }).InfiniteCanvasRuntime = runtime;
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
Reference in New Issue
Block a user