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,12 +1,15 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
@@ -25,6 +28,8 @@ type CanvasNodeProps = {
|
||||
showImageInfo: boolean;
|
||||
resourceLabel?: CanvasResourceReference;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
pluginHost?: CanvasPluginHost;
|
||||
registryVersion?: number;
|
||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
batchCount?: number;
|
||||
@@ -61,6 +66,7 @@ type NodeContentRendererProps = {
|
||||
batchOpening: boolean;
|
||||
batchRecovering: boolean;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
pluginContext?: CanvasNodeContext | null;
|
||||
onContentChange: (nodeId: string, content: string) => void;
|
||||
onStopEditing: () => void;
|
||||
mentionReferences: CanvasResourceReference[];
|
||||
@@ -84,6 +90,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
showImageInfo,
|
||||
resourceLabel,
|
||||
mentionReferences = [],
|
||||
pluginHost,
|
||||
renderPanel,
|
||||
renderNodeContent,
|
||||
batchCount = 0,
|
||||
@@ -110,6 +117,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
}: CanvasNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const definition = getNodeDefinition(data.type);
|
||||
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
|
||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
||||
@@ -259,7 +268,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
startTop: data.position.y,
|
||||
startWidth: data.width,
|
||||
startHeight: data.height,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
|
||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||
};
|
||||
window.addEventListener("mousemove", handleResizeMove);
|
||||
@@ -343,6 +352,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onToggleBatch?.(data.id);
|
||||
return;
|
||||
}
|
||||
if (definition?.onDoubleClick && pluginContext) {
|
||||
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
||||
event.stopPropagation();
|
||||
onViewImage?.(data);
|
||||
@@ -377,6 +390,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
batchOpening={batchOpening}
|
||||
batchRecovering={batchRecovering}
|
||||
renderNodeContent={renderNodeContent}
|
||||
pluginContext={pluginContext}
|
||||
mentionReferences={mentionReferences}
|
||||
onContentChange={onContentChange}
|
||||
onStopEditing={() => setIsEditingContent(false)}
|
||||
@@ -400,7 +414,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
|
||||
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
@@ -413,8 +427,16 @@ function NodeContent(props: NodeContentRendererProps) {
|
||||
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
||||
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
|
||||
if (Renderer) return <Renderer {...props} />;
|
||||
|
||||
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
|
||||
const definition = getNodeDefinition(props.node.type);
|
||||
if (definition?.Content && props.pluginContext) {
|
||||
const PluginContent = definition.Content;
|
||||
return <PluginContent ctx={props.pluginContext} />;
|
||||
}
|
||||
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
@@ -473,10 +495,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
);
|
||||
}
|
||||
|
||||
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
|
||||
未知节点
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
|
||||
<Puzzle className="size-7 opacity-40" />
|
||||
<span className="text-sm">缺少插件</span>
|
||||
<span className="text-[11px] opacity-70">节点类型 “{type}” 的插件未安装或未启用</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user