mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 08:54:23 +08:00
feat(plugin): implement canvas node plugin system with dynamic registration and remote installation
This commit is contained in:
@@ -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, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
|
||||
import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Puzzle, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
@@ -43,6 +43,12 @@ import { useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
||||
import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
|
||||
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
||||
import type { CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
import {
|
||||
CanvasNodeType,
|
||||
type CanvasAssistantImage,
|
||||
@@ -51,6 +57,7 @@ import {
|
||||
type CanvasImageGenerationType,
|
||||
type CanvasNodeData,
|
||||
type CanvasNodeMetadata,
|
||||
type CanvasNodeTypeId,
|
||||
type ConnectionHandle,
|
||||
type ContextMenuState,
|
||||
type Position,
|
||||
@@ -60,6 +67,9 @@ import {
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio } from "@/types/media";
|
||||
|
||||
// 内置节点注册到统一注册表(模块加载时执行一次)
|
||||
registerBuiltinNodes();
|
||||
|
||||
type CanvasClipboard = {
|
||||
nodes: CanvasNodeData[];
|
||||
connections: CanvasConnection[];
|
||||
@@ -104,7 +114,7 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const spec = getNodeSpec(type);
|
||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
@@ -212,11 +222,13 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: {
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: CanvasNodeType) => void; onClose: () => void }) {
|
||||
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
useNodeRegistryVersion();
|
||||
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
|
||||
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
|
||||
data-canvas-no-zoom
|
||||
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -228,12 +240,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频" onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="生成配置" onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Group className="size-5" />} title="组" onClick={() => onCreate(CanvasNodeType.Group)} />
|
||||
{definitions.map((def) => (
|
||||
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -241,6 +250,8 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
||||
|
||||
function InfiniteCanvasPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染
|
||||
const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version);
|
||||
const params = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -322,6 +333,7 @@ function InfiniteCanvasPage() {
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
|
||||
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
||||
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
|
||||
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
|
||||
@@ -602,7 +614,7 @@ function InfiniteCanvasPage() {
|
||||
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
setPendingConnectionCreate(null);
|
||||
setConnecting(null);
|
||||
},
|
||||
@@ -791,8 +803,38 @@ function InfiniteCanvasPage() {
|
||||
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
|
||||
return () => setAgentCanvasContext(null);
|
||||
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
|
||||
|
||||
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
||||
const pluginHost = useMemo<CanvasPluginHost>(
|
||||
() => ({
|
||||
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
|
||||
getNodes: () => nodesRef.current,
|
||||
getConnections: () => connectionsRef.current,
|
||||
getUpstream: (nodeId) => connectionsRef.current.filter((conn) => conn.toNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||
getDownstream: (nodeId) => connectionsRef.current.filter((conn) => conn.fromNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
|
||||
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
|
||||
applyOps: (ops) => applyAgentOps(ops),
|
||||
}),
|
||||
[applyAgentOps],
|
||||
);
|
||||
|
||||
const renderPluginPanel = useCallback(
|
||||
(panelNode: CanvasNodeData) => {
|
||||
const Panel = getNodeDefinition(panelNode.type)?.Panel;
|
||||
if (!Panel) return null;
|
||||
const ctx = buildNodeContext(pluginHost, panelNode, theme, viewportRef.current.k);
|
||||
return <Panel ctx={ctx} onClose={() => setDialogNodeId(null)} />;
|
||||
},
|
||||
[pluginHost, theme],
|
||||
);
|
||||
|
||||
// 启动时加载已安装的远程插件
|
||||
useEffect(() => {
|
||||
void ensurePluginsLoaded();
|
||||
}, []);
|
||||
const createNode = useCallback(
|
||||
(type: CanvasNodeType, position?: Position) => {
|
||||
(type: CanvasNodeTypeId, position?: Position) => {
|
||||
const targetPosition = position || getCanvasCenter();
|
||||
const configMetadata =
|
||||
type === CanvasNodeType.Config
|
||||
@@ -807,7 +849,8 @@ function InfiniteCanvasPage() {
|
||||
setNodes((prev) => [...prev, newNode]);
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
||||
const definition = getNodeDefinition(type);
|
||||
if (definition?.Panel || (isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group)) setDialogNodeId(newNode.id);
|
||||
},
|
||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||
);
|
||||
@@ -2518,6 +2561,7 @@ function InfiniteCanvasPage() {
|
||||
onCreateProject={createAndOpenProject}
|
||||
onDeleteProject={deleteCurrentProject}
|
||||
onImportImage={() => handleUploadRequest()}
|
||||
onOpenPlugins={() => setPluginManagerOpen(true)}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
agentOpen={agentPanelOpen}
|
||||
@@ -2600,8 +2644,12 @@ function InfiniteCanvasPage() {
|
||||
showImageInfo={showImageInfo}
|
||||
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
||||
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
||||
pluginHost={pluginHost}
|
||||
registryVersion={nodeRegistryVersion}
|
||||
renderPanel={(panelNode) =>
|
||||
panelNode.type === CanvasNodeType.Config ? (
|
||||
getNodeDefinition(panelNode.type)?.Panel ? (
|
||||
renderPluginPanel(panelNode)
|
||||
) : panelNode.type === CanvasNodeType.Config ? (
|
||||
<CanvasConfigComposer
|
||||
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||
inputs={configInputsById.get(panelNode.id) || []}
|
||||
@@ -2692,6 +2740,7 @@ function InfiniteCanvasPage() {
|
||||
<CanvasNodeHoverToolbar
|
||||
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
|
||||
viewport={viewport}
|
||||
extraTools={toolbarNode ? getNodeDefinition(toolbarNode.type)?.toolbar?.(buildNodeContext(pluginHost, toolbarNode, theme, viewport.k)) : undefined}
|
||||
onKeep={keepNodeToolbar}
|
||||
onLeave={hideNodeToolbar}
|
||||
onInfo={(node) => setInfoNodeId(node.id)}
|
||||
@@ -2768,6 +2817,7 @@ function InfiniteCanvasPage() {
|
||||
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
|
||||
|
||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
|
||||
<CanvasPluginManagerModal open={pluginManagerOpen} onClose={() => setPluginManagerOpen(false)} />
|
||||
|
||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
||||
|
||||
@@ -2839,6 +2889,7 @@ function CanvasTopBar({
|
||||
onCreateProject,
|
||||
onDeleteProject,
|
||||
onImportImage,
|
||||
onOpenPlugins,
|
||||
onUndo,
|
||||
onRedo,
|
||||
agentOpen,
|
||||
@@ -2859,6 +2910,7 @@ function CanvasTopBar({
|
||||
onCreateProject: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onImportImage: () => void;
|
||||
onOpenPlugins: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
agentOpen: boolean;
|
||||
@@ -2895,6 +2947,7 @@ function CanvasTopBar({
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
|
||||
Reference in New Issue
Block a user