mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
refactor(canvas-project): extract plugin host into usePluginHost hook
Move the plugin node host capabilities (pluginAi generation bridge, pluginHost canvas accessors, renderPluginPanel, buildNodeToolbarItems) and the plugin-load effect out of InfiniteCanvasPage into usePluginHost. The hook receives the shared config/refs/setters explicitly and keeps identical dependency arrays. This also relocates the AI-service and plugin imports (requestVideoGeneration, selectableModelsByCapability, buildNodeContext, ensurePluginsLoaded, the canvas-plugin types, etc.) that were only used by this cluster. project.tsx is now ~2950 lines, down from 3536 at the start of the refactor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||||
|
|
||||||
|
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
|
||||||
|
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||||
|
import { decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||||
|
import { buildGenerationConfig } from "@/lib/canvas/canvas-generation-helpers";
|
||||||
|
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||||
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||||
|
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||||
|
import type { ReferenceImage } from "@/types/image";
|
||||||
|
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
|
||||||
|
import type { CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||||
|
|
||||||
|
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
|
||||||
|
type PluginHostParams = {
|
||||||
|
effectiveConfig: AiConfig;
|
||||||
|
isAiConfigReady: (config: AiConfig, model: string) => boolean;
|
||||||
|
openConfigDialog: (open: boolean) => void;
|
||||||
|
theme: CanvasTheme;
|
||||||
|
nodesRef: MutableRefObject<CanvasNodeData[]>;
|
||||||
|
connectionsRef: MutableRefObject<CanvasConnection[]>;
|
||||||
|
viewportRef: MutableRefObject<ViewportTransform>;
|
||||||
|
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||||
|
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
applyAgentOps: (ops?: CanvasAgentOp[]) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件节点宿主能力:把宿主侧的 AI 生成、画布读写、面板开关等封装成插件可调用的 host/ai 对象,
|
||||||
|
* 并在挂载时加载已安装的远程插件。返回给画布用于渲染插件面板与工具条。
|
||||||
|
*/
|
||||||
|
export function usePluginHost(params: PluginHostParams) {
|
||||||
|
const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params;
|
||||||
|
|
||||||
|
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
||||||
|
const pluginAi = useMemo<CanvasPluginAi>(() => {
|
||||||
|
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
|
||||||
|
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
|
||||||
|
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
|
||||||
|
const ensureReady = (config: AiConfig) => {
|
||||||
|
if (!isAiConfigReady(config, config.model)) {
|
||||||
|
openConfigDialog(true);
|
||||||
|
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
generateImage: async (prompt, options) => {
|
||||||
|
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
|
||||||
|
ensureReady(config);
|
||||||
|
const references = toReferences(options?.references);
|
||||||
|
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
|
||||||
|
return { images: items.map((item) => item.dataUrl) };
|
||||||
|
},
|
||||||
|
generateVideo: async (prompt, options) => {
|
||||||
|
const config = {
|
||||||
|
...buildGenerationConfig(effectiveConfig, undefined, "video"),
|
||||||
|
...(options?.model ? { model: options.model } : {}),
|
||||||
|
...(options?.size ? { size: options.size } : {}),
|
||||||
|
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
|
||||||
|
};
|
||||||
|
ensureReady(config);
|
||||||
|
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
|
||||||
|
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
|
||||||
|
},
|
||||||
|
generateText: async (prompt, options) => {
|
||||||
|
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "text"), ...(options?.model ? { model: options.model } : {}) };
|
||||||
|
ensureReady(config);
|
||||||
|
const messages: AiTextMessage[] = [...(options?.system ? [{ role: "system" as const, content: options.system }] : []), { role: "user" as const, content: prompt }];
|
||||||
|
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
|
||||||
|
return { text };
|
||||||
|
},
|
||||||
|
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
|
||||||
|
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
|
||||||
|
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
|
||||||
|
};
|
||||||
|
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
|
||||||
|
|
||||||
|
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),
|
||||||
|
ai: pluginAi,
|
||||||
|
openPanel: (nodeId) => setDialogNodeId(nodeId),
|
||||||
|
closePanel: () => setDialogNodeId(null),
|
||||||
|
}),
|
||||||
|
[applyAgentOps, pluginAi],
|
||||||
|
);
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
|
||||||
|
const buildNodeToolbarItems = useCallback(
|
||||||
|
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
|
||||||
|
const definition = getNodeDefinition(node.type);
|
||||||
|
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
|
||||||
|
const custom = definition?.toolbar?.(ctx) || [];
|
||||||
|
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
|
||||||
|
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
|
||||||
|
const interactive = Boolean(node.metadata?.interactive);
|
||||||
|
const toggle: CanvasNodeToolbarItem = {
|
||||||
|
id: "node-interaction-toggle",
|
||||||
|
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)",
|
||||||
|
label: interactive ? "移动" : "交互",
|
||||||
|
icon: interactive ? "✋" : "🖐",
|
||||||
|
active: interactive,
|
||||||
|
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
|
||||||
|
};
|
||||||
|
return [toggle, ...custom];
|
||||||
|
},
|
||||||
|
[pluginHost, theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 启动时加载已安装的远程插件
|
||||||
|
useEffect(() => {
|
||||||
|
void ensurePluginsLoaded();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pluginHost, renderPluginPanel, buildNodeToolbarItems };
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|||||||
import { Group, Video } from "lucide-react";
|
import { Group, Video } from "lucide-react";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
|
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||||
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
|
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
|
||||||
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||||
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||||
import { uploadImage } from "@/services/image-storage";
|
import { uploadImage } from "@/services/image-storage";
|
||||||
import { uploadMediaFile } from "@/services/file-storage";
|
import { uploadMediaFile } from "@/services/file-storage";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -41,6 +41,7 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
|||||||
import { useAgentStore } from "@/stores/use-agent-store";
|
import { useAgentStore } from "@/stores/use-agent-store";
|
||||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||||
import { useAgentBridge } from "@/pages/canvas/hooks/use-agent-bridge";
|
import { useAgentBridge } from "@/pages/canvas/hooks/use-agent-bridge";
|
||||||
|
import { usePluginHost } from "@/pages/canvas/hooks/use-plugin-host";
|
||||||
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||||
import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory";
|
import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory";
|
||||||
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
|
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
|
||||||
@@ -63,14 +64,11 @@ import {
|
|||||||
sourceNodeReferenceImages,
|
sourceNodeReferenceImages,
|
||||||
} from "@/lib/canvas/canvas-generation-helpers";
|
} from "@/lib/canvas/canvas-generation-helpers";
|
||||||
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, 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 { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
|
||||||
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
||||||
import { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell";
|
import { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell";
|
||||||
import { CanvasTopBar } from "@/components/canvas/canvas-top-bar";
|
import { CanvasTopBar } from "@/components/canvas/canvas-top-bar";
|
||||||
import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus";
|
import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus";
|
||||||
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
|
|
||||||
import {
|
import {
|
||||||
CanvasNodeType,
|
CanvasNodeType,
|
||||||
type CanvasAssistantImage,
|
type CanvasAssistantImage,
|
||||||
@@ -668,110 +666,18 @@ function InfiniteCanvasPage() {
|
|||||||
setContextMenu,
|
setContextMenu,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
const { pluginHost, renderPluginPanel, buildNodeToolbarItems } = usePluginHost({
|
||||||
const pluginAi = useMemo<CanvasPluginAi>(() => {
|
effectiveConfig,
|
||||||
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
|
isAiConfigReady,
|
||||||
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
|
openConfigDialog,
|
||||||
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
|
theme,
|
||||||
const ensureReady = (config: AiConfig) => {
|
nodesRef,
|
||||||
if (!isAiConfigReady(config, config.model)) {
|
connectionsRef,
|
||||||
openConfigDialog(true);
|
viewportRef,
|
||||||
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥");
|
setNodes,
|
||||||
}
|
setDialogNodeId,
|
||||||
};
|
applyAgentOps,
|
||||||
return {
|
});
|
||||||
generateImage: async (prompt, options) => {
|
|
||||||
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
|
|
||||||
ensureReady(config);
|
|
||||||
const references = toReferences(options?.references);
|
|
||||||
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
|
|
||||||
return { images: items.map((item) => item.dataUrl) };
|
|
||||||
},
|
|
||||||
generateVideo: async (prompt, options) => {
|
|
||||||
const config = {
|
|
||||||
...buildGenerationConfig(effectiveConfig, undefined, "video"),
|
|
||||||
...(options?.model ? { model: options.model } : {}),
|
|
||||||
...(options?.size ? { size: options.size } : {}),
|
|
||||||
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
|
|
||||||
};
|
|
||||||
ensureReady(config);
|
|
||||||
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
|
|
||||||
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
|
|
||||||
},
|
|
||||||
generateText: async (prompt, options) => {
|
|
||||||
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "text"), ...(options?.model ? { model: options.model } : {}) };
|
|
||||||
ensureReady(config);
|
|
||||||
const messages: AiTextMessage[] = [...(options?.system ? [{ role: "system" as const, content: options.system }] : []), { role: "user" as const, content: prompt }];
|
|
||||||
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
|
|
||||||
return { text };
|
|
||||||
},
|
|
||||||
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
|
|
||||||
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
|
|
||||||
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
|
|
||||||
};
|
|
||||||
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
|
|
||||||
|
|
||||||
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),
|
|
||||||
ai: pluginAi,
|
|
||||||
openPanel: (nodeId) => setDialogNodeId(nodeId),
|
|
||||||
closePanel: () => setDialogNodeId(null),
|
|
||||||
}),
|
|
||||||
[applyAgentOps, pluginAi],
|
|
||||||
);
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
|
|
||||||
const buildNodeToolbarItems = useCallback(
|
|
||||||
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
|
|
||||||
const definition = getNodeDefinition(node.type);
|
|
||||||
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
|
|
||||||
const custom = definition?.toolbar?.(ctx) || [];
|
|
||||||
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
|
|
||||||
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
|
|
||||||
const interactive = Boolean(node.metadata?.interactive);
|
|
||||||
const toggle: CanvasNodeToolbarItem = {
|
|
||||||
id: "node-interaction-toggle",
|
|
||||||
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)",
|
|
||||||
label: interactive ? "移动" : "交互",
|
|
||||||
icon: interactive ? "✋" : "🖐",
|
|
||||||
active: interactive,
|
|
||||||
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
|
|
||||||
};
|
|
||||||
return [toggle, ...custom];
|
|
||||||
},
|
|
||||||
[pluginHost, theme],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 启动时加载已安装的远程插件
|
|
||||||
useEffect(() => {
|
|
||||||
void ensurePluginsLoaded();
|
|
||||||
}, []);
|
|
||||||
const createNode = useCallback(
|
const createNode = useCallback(
|
||||||
(type: CanvasNodeTypeId, position?: Position) => {
|
(type: CanvasNodeTypeId, position?: Position) => {
|
||||||
const targetPosition = position || getCanvasCenter();
|
const targetPosition = position || getCanvasCenter();
|
||||||
|
|||||||
Reference in New Issue
Block a user