Compare commits

..

7 Commits

24 changed files with 1130 additions and 304 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile
run: bun install
- name: Build
working-directory: web
+11
View File
@@ -2,6 +2,17 @@
## Unreleased
## v0.8.1 - 2026-07-16
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
+ [优化] 3D 全景(1.1.0)支持上传与 AI 生成并升级查看器;
+ [优化] HTML 节点(1.2.0)迁移到统一交互开关;
+ [优化] 便利贴(1.1.0)可拖动移动、自选颜色、移除资源角标与衍生功能;
+ [优化] SVG 节点(1.1.0)透明背景融入画布、可拖动、去除默认值;
+ [优化] 画布节点渲染性能:memo 化节点回调,交互时不再全量重渲染;
+ [修复] 修复 Markdown 节点在点击/移动视角时重复渲染导致图片反复请求的问题;
+ [修复] 修复插件版本号显示不更新,插件面板新增可升级绿点提醒。
## v0.8.0 - 2026-07-15
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
+1 -1
View File
@@ -1 +1 @@
v0.8.0
v0.8.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-html",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"type": "module",
"description": "Infinite Canvas HTML 节点插件",
+99 -18
View File
@@ -1,10 +1,67 @@
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
import { definePlugin, useMemo, useState } from "@infinite-canvas/plugin-sdk";
// 交互:预览态 iframe 默认 pointer-events:none —— 鼠标事件穿透到宿主节点体,
// 因此点节点任意位置都能拖动,无需在节点上加任何标题栏/按钮。
// 「编辑/预览」与「交互」开关都放在节点外的悬浮工具条(toolbar 扩展点),状态存 metadata。
import { definePlugin, useMemo, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
const EDITOR_FONT = 12;
const EDITOR_LINE = 20;
function HtmlEditor({ ctx, value }: { ctx: CanvasNodeContentProps["ctx"]; value: string }) {
const gutterRef = useRef<HTMLDivElement>(null);
// 行数:按换行统计,至少 1 行;value 变化时重算
const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
const [scrollTop, setScrollTop] = useState(0);
const codeStyle = { fontFamily: "monospace", fontSize: EDITOR_FONT, lineHeight: `${EDITOR_LINE}px`, boxSizing: "border-box" } as const;
return (
<div data-canvas-no-zoom style={{ height: "100%", width: "100%", display: "flex", overflow: "hidden", borderRadius: 16, background: ctx.theme.node.fill }} onMouseDown={(e) => e.stopPropagation()}>
{/* 行号槽:跟随文本域滚动,不可单独滚动 */}
<div
ref={gutterRef}
aria-hidden
style={{
...codeStyle,
flex: "0 0 auto",
padding: "16px 8px 16px 12px",
textAlign: "right",
color: ctx.theme.node.placeholder,
background: `${ctx.theme.toolbar.panel}66`,
borderRight: `1px solid ${ctx.theme.node.stroke}`,
overflow: "hidden",
userSelect: "none",
whiteSpace: "pre",
}}
>
{/* 用负 margin 让整列跟随 scrollTop 平移,和 textarea 同步 */}
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
</div>
<textarea
autoFocus
value={value}
placeholder="<div>Hello, {{input}}</div>"
spellCheck={false}
wrap="off"
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ ...codeStyle, flex: "1 1 auto", minWidth: 0, height: "100%", resize: "none", background: "transparent", padding: "16px 16px 16px 12px", outline: "none", border: "none", color: ctx.theme.node.text, whiteSpace: "pre", overflow: "auto" }}
/>
</div>
);
}
function HtmlContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
const value = ctx.node.metadata?.content || "";
const editing = Boolean(ctx.node.metadata?.editing);
const upstreamText = useMemo(
() =>
ctx
@@ -16,23 +73,29 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
);
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
const toggle = { position: "absolute", right: 8, top: 8, zIndex: 20, width: 32, height: 32, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: `${ctx.theme.toolbar.panel}dd`, color: ctx.theme.node.text, cursor: "pointer" } as const;
if (editing) {
return <HtmlEditor ctx={ctx} value={value} />;
}
if (!value) {
return (
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
<span style={{ fontSize: 26 }}>{"</>"}</span>
<span style={{ fontSize: 13 }}>, HTML</span>
</div>
);
}
// 预览态:iframe 的鼠标交互由宿主「交互 ⇄ 移动」开关统一控制(见 interactionToggle),
// 这里无需再手动做 pointer-events 穿透。data-canvas-no-zoom 保证交互时滚动作用于页面而非缩放画布。
return (
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
{editing ? "👁" : "✎"}
</button>
{editing ? (
<textarea autoFocus value={value} placeholder="<div>Hello, {{input}}</div>" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 12, outline: "none", border: "none", color: ctx.theme.node.text }} />
) : value ? (
<iframe title="html-preview" sandbox="allow-scripts allow-forms" style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff" }} srcDoc={html} />
) : (
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
<span style={{ fontSize: 26 }}>{"</>"}</span>
<span style={{ fontSize: 14 }}> HTML </span>
</div>
)}
<div data-canvas-no-zoom style={{ position: "relative", height: "100%", width: "100%" }}>
<iframe
title="html-preview"
sandbox="allow-scripts allow-forms"
srcDoc={html}
style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff", display: "block" }}
/>
</div>
);
}
@@ -40,7 +103,7 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
export default definePlugin({
id: "html",
name: "HTML 节点",
version: "1.0.0",
version: "1.2.0",
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
nodes: [
{
@@ -51,7 +114,25 @@ export default definePlugin({
defaultSize: { width: 420, height: 320 },
defaultMetadata: { content: "" },
minimapColor: "#ec4899",
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互(编辑器始终可操作)并隐藏该开关
interactionToggle: true,
forceInteractive: (node) => Boolean(node.metadata?.editing),
Content: HtmlContent,
// 仅保留「编辑/预览」开关;交互/移动 由宿主自动注入
toolbar: (ctx) => {
const editing = Boolean(ctx.node.metadata?.editing);
return [
{
id: "html-toggle-edit",
title: editing ? "预览渲染结果" : "编辑 HTML 源码",
label: editing ? "预览" : "编辑",
icon: editing ? "👁" : "✎",
active: editing,
onClick: () => ctx.updateMetadata({ editing: !editing }),
},
];
},
},
],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-markdown",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas Markdown 节点插件",
+75 -21
View File
@@ -1,6 +1,10 @@
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
// 切「交互」态可滚动/选择;编辑态强制可交互。
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
import { definePlugin, useEffect, useMemo, useState } from "@infinite-canvas/plugin-sdk";
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
import css from "./styles.css";
@@ -15,44 +19,76 @@ function loadMarked(): Promise<Marked> {
return markedPromise;
}
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
// marked 就绪状态:已缓存则首帧即为 true,避免重渲染/重挂载时闪成空白
const [markedReady, setMarkedReady] = useState(Boolean(marked));
const value = ctx.node.metadata?.content || "";
const PLACEHOLDER = "*选中节点,点上方工具条的 ✎ 编辑 Markdown*";
// 解析结果按源码缓存(模块级):同一段 Markdown 只解析一次,重复渲染/重挂载都命中缓存,
// 返回的是同一个字符串引用,配合下方「值不变不写 DOM」彻底避免图片被重新请求。
const htmlCache = new Map<string, string>();
function renderMarkdown(source: string): string {
if (!marked) return "";
const key = source || PLACEHOLDER;
let out = htmlCache.get(key);
if (out === undefined) {
out = marked.parse(key);
htmlCache.set(key, out);
}
return out;
}
// 预览态:用 ref 手动写 innerHTML,且仅在 html 真正变化时写入。
// 这样宿主的任何重渲染(点击、移动视角、选中态变化等)都不会触碰已渲染的 DOM,图片不会重新加载。
function MarkdownPreview({ ctx }: CanvasNodeContentProps) {
const [, force] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const lastHtml = useRef<string | null>(null);
// marked 未就绪时按需加载,加载完触发一次重渲染填充内容
useEffect(() => {
if (marked) return;
let alive = true;
loadMarked().then(() => alive && setMarkedReady(true));
loadMarked().then(() => alive && force((n) => n + 1));
return () => {
alive = false;
};
}, []);
// marked 就绪后同步计算 HTML(按 value 记忆),不再用异步 setState 填充,消除闪烁
const html = useMemo(() => (marked ? marked.parse(value || "*双击右上角按钮编辑 Markdown*") : ""), [value, markedReady]);
const source = (ctx.node.metadata?.content as string | undefined) || "";
const html = renderMarkdown(source);
const toggle = { position: "absolute", right: 8, top: 8, zIndex: 20, width: 32, height: 32, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: `${ctx.theme.toolbar.panel}dd`, color: ctx.theme.node.text, cursor: "pointer" } as const;
useEffect(() => {
const el = ref.current;
if (!el || lastHtml.current === html) return; // 内容没变:不动 DOM,已加载的图片保持原样
el.innerHTML = html;
lastHtml.current = html;
}, [html]);
return <div ref={ref} className="cnv-md" data-canvas-no-zoom onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", color: ctx.theme.node.text }} />;
}
function MarkdownEditor({ ctx }: CanvasNodeContentProps) {
const value = (ctx.node.metadata?.content as string | undefined) || "";
return (
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑"}>
{editing ? "👁" : "✎"}
</button>
{editing ? (
<textarea autoFocus value={value} placeholder="# 输入 Markdown" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }} />
) : (
<div className="cnv-md" onWheel={(e) => e.stopPropagation()} style={{ color: ctx.theme.node.text }} dangerouslySetInnerHTML={{ __html: html }} />
)}
</div>
<textarea
autoFocus
value={value}
placeholder="# 输入 Markdown"
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, boxSizing: "border-box", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }}
/>
);
}
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
return ctx.node.metadata?.editing ? <MarkdownEditor ctx={ctx} /> : <MarkdownPreview ctx={ctx} />;
}
export default definePlugin({
id: "markdown",
name: "Markdown 节点",
version: "1.0.0",
version: "1.1.0",
description: "在画布中编辑与渲染 Markdown",
css,
nodes: [
@@ -64,8 +100,26 @@ export default definePlugin({
defaultSize: { width: 360, height: 300 },
defaultMetadata: { content: "" },
minimapColor: "#6366f1",
hidePanel: true, // 纯展示/编辑节点:不弹出下方生图面板
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互并隐藏该开关
interactionToggle: true,
forceInteractive: (node) => Boolean(node.metadata?.editing),
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
Content: MarkdownContent,
// 仅保留「编辑/预览」开关(状态存 metadata.editing);交互/移动 由宿主自动注入
toolbar: (ctx) => {
const editing = Boolean(ctx.node.metadata?.editing);
return [
{
id: "md-toggle-edit",
title: editing ? "预览渲染结果" : "编辑 Markdown 源码",
label: editing ? "预览" : "编辑",
icon: editing ? "👁" : "✎",
active: editing,
onClick: () => ctx.updateMetadata({ editing: !editing }),
},
];
},
},
],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-panorama",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas 3D 全景节点插件",
+269 -86
View File
@@ -1,6 +1,8 @@
// 3D 全景节点:等距柱状(equirectangular)全景查看器。three.js 从 CDN 按需加载
import { definePlugin, useEffect, useRef } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
// 3D 全景节点:等距柱状(equirectangular)360° 全景查看器。
// 三种取图方式:① 从上游图片节点自动取图 ② 本地上传 ③ AI 生成(复用宿主内置生成面板)。
// three.js 从 CDN 按需加载,不打进 bundle。AI 生成通过 useBuiltinPanel 声明,结果写回本节点。
import { definePlugin, useCallback, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps, CanvasNodeContext } from "@infinite-canvas/plugin-sdk";
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
let threePromise: Promise<any> | undefined;
@@ -9,120 +11,301 @@ function loadThree(): Promise<any> {
return threePromise;
}
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
// AI 生成的固定前置提示词:约束模型产出可用于球面贴图的 equirectangular 全景图。
// 用户只需描述场景内容,这段会自动拼在前面,保证 2:1、无缝、无畸变文字等。
const PANORAMA_SYSTEM_PROMPT =
"A seamless 360-degree equirectangular panorama, 2:1 aspect ratio, full spherical VR photo, " +
"horizontally wrapping seamlessly at the left and right edges, no visible seam, no distortion artifacts, " +
"even horizon, no text, no watermark. Scene: ";
// ---------------------------------------------------------------------------
// 全景查看器:球体内壁贴等距柱状纹理,指针拖动转视角、滚轮缩放 fov。
// 自适应容器尺寸(ResizeObserver);超过 GPU 上限的大图先降采样,避免加载失败。
// ---------------------------------------------------------------------------
function PanoramaViewer({ src, ctx }: { src: string; ctx: CanvasNodeContext }) {
const mountRef = useRef<HTMLDivElement>(null);
const upstreamImage = ctx
.getUpstream()
.map((node) => node.metadata?.content)
.find(Boolean);
const source = ctx.node.metadata?.content || upstreamImage;
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
const mount = mountRef.current;
if (!mount || !source) return;
if (!mount || !src) return;
let disposed = false;
let cleanup = () => {};
let frame = 0;
let renderer: any = null;
let geometry: any = null;
let material: any = null;
let texture: any = null;
let resizeObserver: ResizeObserver | null = null;
let cleanupEvents = () => {};
loadThree().then((THREE: any) => {
if (disposed || !mountRef.current) return;
const width = mount.clientWidth || 400;
const height = mount.clientHeight || 300;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
mount.appendChild(renderer.domElement);
setStatus("loading");
const geometry = new THREE.SphereGeometry(50, 60, 40);
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
const texture = new THREE.TextureLoader().load(source);
texture.colorSpace = THREE.SRGBColorSpace;
const sphere = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map: texture }));
scene.add(sphere);
loadThree()
.then((THREE: any) => {
if (disposed || !mountRef.current) return;
let lon = 0;
let lat = 0;
let dragging = false;
let startX = 0;
let startY = 0;
let startLon = 0;
let startLat = 0;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
const target = new THREE.Vector3();
const onDown = (event: PointerEvent) => {
dragging = true;
startX = event.clientX;
startY = event.clientY;
startLon = lon;
startLat = lat;
};
const onMove = (event: PointerEvent) => {
if (!dragging) return;
lon = startLon - (event.clientX - startX) * 0.2;
lat = Math.max(-85, Math.min(85, startLat + (event.clientY - startY) * 0.2));
};
const onUp = () => (dragging = false);
const canvas: HTMLCanvasElement = renderer.domElement;
canvas.style.cursor = "grab";
canvas.addEventListener("pointerdown", onDown);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 1);
const canvas: HTMLCanvasElement = renderer.domElement;
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.display = "block";
canvas.style.cursor = "grab";
mount.appendChild(canvas);
const maxTextureSide = Math.min(renderer.capabilities.maxTextureSize || 4096, 4096);
let frame = 0;
const animate = () => {
frame = requestAnimationFrame(animate);
if (!dragging) lon += 0.03; // 缓慢自转
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon);
camera.lookAt(new THREE.Vector3(Math.sin(phi) * Math.cos(theta), Math.cos(phi), Math.sin(phi) * Math.sin(theta)));
renderer.render(scene, camera);
};
animate();
geometry = new THREE.SphereGeometry(500, 96, 64);
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
material = new THREE.MeshBasicMaterial({ color: 0xffffff });
scene.add(new THREE.Mesh(geometry, material));
cleanup = () => {
cancelAnimationFrame(frame);
canvas.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
renderer.dispose();
geometry.dispose();
texture.dispose();
if (canvas.parentElement === mount) mount.removeChild(canvas);
};
});
// 视角状态
let lon = 0;
let lat = 0;
let fov = 70;
let dragging = false;
let startX = 0;
let startY = 0;
let startLon = 0;
let startLat = 0;
const onDown = (event: PointerEvent) => {
dragging = true;
startX = event.clientX;
startY = event.clientY;
startLon = lon;
startLat = lat;
canvas.style.cursor = "grabbing";
canvas.setPointerCapture?.(event.pointerId);
};
const onMove = (event: PointerEvent) => {
if (!dragging) return;
lon = startLon - (event.clientX - startX) * 0.12;
lat = Math.max(-84, Math.min(84, startLat + (event.clientY - startY) * 0.12));
};
const onUp = () => {
dragging = false;
canvas.style.cursor = "grab";
};
const onWheel = (event: WheelEvent) => {
event.preventDefault();
fov = Math.max(38, Math.min(86, fov + event.deltaY * 0.035));
};
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
canvas.addEventListener("wheel", onWheel, { passive: false });
cleanupEvents = () => {
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
canvas.removeEventListener("wheel", onWheel);
};
const resize = () => {
if (!renderer || !mountRef.current) return;
const width = Math.max(1, mountRef.current.clientWidth);
const height = Math.max(1, mountRef.current.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(mount);
resize();
const render = () => {
if (disposed || !renderer) return;
if (!dragging) lon += 0.02; // 缓慢自转
camera.fov = fov;
camera.updateProjectionMatrix();
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon);
target.set(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
camera.lookAt(target);
renderer.render(scene, camera);
frame = requestAnimationFrame(render);
};
render();
// 载入纹理:大图降采样到 GPU 上限,避免超限导致黑屏/失败
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
if (disposed || !material) return;
try {
const w = image.naturalWidth || image.width;
const h = image.naturalHeight || image.height;
const scale = Math.min(1, maxTextureSide / w, maxTextureSide / h);
let source: HTMLImageElement | HTMLCanvasElement = image;
if (scale < 1) {
const c = document.createElement("canvas");
c.width = Math.max(1, Math.round(w * scale));
c.height = Math.max(1, Math.round(h * scale));
c.getContext("2d")?.drawImage(image, 0, 0, c.width, c.height);
source = c;
}
texture = source instanceof HTMLCanvasElement ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.needsUpdate = true;
material.map = texture;
material.needsUpdate = true;
setStatus("ready");
} catch {
setStatus("error");
}
};
image.onerror = () => !disposed && setStatus("error");
image.src = src;
})
.catch(() => !disposed && setStatus("error"));
return () => {
disposed = true;
cleanup();
cleanupEvents();
resizeObserver?.disconnect();
if (frame) cancelAnimationFrame(frame);
texture?.dispose?.();
material?.dispose?.();
geometry?.dispose?.();
if (renderer) {
renderer.domElement?.remove?.();
renderer.dispose?.();
renderer.forceContextLoss?.();
}
};
}, [source]);
}, [src]);
if (!source) {
return (
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
<span style={{ fontSize: 26 }}>🌐</span>
<span style={{ fontSize: 14 }}></span>
return (
<div style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }}>
<div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} />
{status !== "ready" ? (
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", color: ctx.theme.node.placeholder, fontSize: 13, background: status === "error" ? "rgba(0,0,0,0.6)" : `linear-gradient(135deg, ${ctx.theme.node.fill}, #000)` }}>
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。
// 整块不可点,避免误触;拖动节点照常。
// ---------------------------------------------------------------------------
function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) {
const fileRef = useRef<HTMLInputElement>(null);
// 本地上传:读为 dataURL 写入自身 metadata.content
const onPick = useCallback(
(file: File | undefined) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () => ctx.updateMetadata({ content: String(reader.result || "") });
reader.readAsDataURL(file);
},
[ctx],
);
const t = ctx.theme;
const baseBtn = {
display: "inline-flex",
alignItems: "center",
gap: 6,
cursor: "pointer",
borderRadius: 999,
padding: "9px 18px",
fontSize: 13,
fontWeight: 600,
border: `1px solid ${t.node.stroke}`,
background: t.node.fill,
color: t.node.text,
} as const;
return (
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 20, borderRadius: 16, boxSizing: "border-box", background: t.node.fill }}>
<span style={{ fontSize: 34 }}>🌐</span>
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => onPick(e.target.files?.[0])} />
<button type="button" style={baseBtn} onClick={() => fileRef.current?.click()}>
🖼
</button>
<button type="button" style={{ ...baseBtn, border: "none", background: t.toolbar.activeBg, color: t.toolbar.activeText }} onClick={() => ctx.openPanel()}>
AI
</button>
</div>
);
}
return <div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }} />;
<span style={{ fontSize: 12, color: t.node.placeholder, textAlign: "center" }}> 2:1 </span>
</div>
);
}
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
const upstreamImage = ctx
.getUpstream()
.map((node) => node.metadata?.content)
.find((content): content is string => typeof content === "string" && Boolean(content));
const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
if (!source) return <PanoramaEmpty ctx={ctx} />;
return <PanoramaViewer src={source} ctx={ctx} />;
}
export default definePlugin({
id: "panorama",
name: "3D 全景节点",
version: "1.0.0",
description: "查看 360° 等距柱状全景图,可从上游图片节点取图",
version: "1.1.0",
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
nodes: [
{
type: "panorama:viewer",
title: "3D 全景",
icon: "🌐",
description: "360° 全景查看器",
description: "360° 全景查看器(上传 / AI 生成)",
defaultSize: { width: 480, height: 300 },
defaultMetadata: {},
minimapColor: "#0ea5e9",
// 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
interactionToggle: true,
// 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
// 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
// 作为上游被消费时,输出自身全景图,供下游节点引用
resource: (node) => {
const content = node.metadata?.content;
return typeof content === "string" && content ? { kind: "image", url: content } : null;
},
Content: PanoramaContent,
// 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
toolbar: (ctx) => {
const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
return [
{
id: "panorama-generate",
title: "用 AI 生成全景图(打开下方面板)",
label: "AI 生成",
icon: "✨",
onClick: () => ctx.openPanel(),
},
...(hasOwnContent
? [
{
id: "panorama-reset",
title: "清空当前全景图,重新上传",
label: "换图",
icon: "🔄",
onClick: () => ctx.updateMetadata({ content: "" }),
},
]
: []),
];
},
},
],
});
+26 -15
View File
@@ -3,7 +3,7 @@
// CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。
// 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。
import { build } from "esbuild";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,15 +11,24 @@ const root = dirname(fileURLToPath(import.meta.url));
const outDir = join(root, "dist");
const nodeModules = join(root, "node_modules");
// 官方插件元数据(单一真源:清单从这里生成)。新增官方插件在此登记即可。
// 官方插件登记表:新增官方插件在此登记即可。
// 版本不在这里写死 —— 从各插件的 package.json 读取(单一真源),
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
const OFFICIAL = [
{ id: "markdown", dir: "markdown", name: "Markdown 节点", version: "1.0.0", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
{ id: "svg", dir: "svg", name: "SVG 节点", version: "1.0.0", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
{ id: "html", dir: "html", name: "HTML 节点", version: "1.0.0", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
{ id: "panorama", dir: "panorama", name: "3D 全景节点", version: "1.0.0", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", version: "1.0.0", description: "可换色、可编辑、可衍生文本节点的便利贴", icon: "📌" },
{ id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
{ id: "svg", dir: "svg", name: "SVG 节点", description: "透明背景渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
{ id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
{ id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", description: "可自选颜色、双击编辑、拖动即可移动的便利贴", icon: "📌" },
];
// 读取插件 package.json 的 version 作为清单版本的唯一来源
async function readPluginVersion(dir) {
const pkg = JSON.parse(await readFile(join(root, "..", dir, "package.json"), "utf8"));
if (!pkg.version) throw new Error(`插件 ${dir} 的 package.json 缺少 version`);
return pkg.version;
}
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
@@ -45,14 +54,16 @@ for (const plugin of OFFICIAL) {
const manifest = {
// 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL
version: 1,
plugins: OFFICIAL.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
icon: plugin.icon,
entry: `${plugin.id}.js`,
})),
plugins: await Promise.all(
OFFICIAL.map(async (plugin) => ({
id: plugin.id,
name: plugin.name,
version: await readPluginVersion(plugin.dir),
description: plugin.description,
icon: plugin.icon,
entry: `${plugin.id}.js`,
})),
),
};
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`);
+94 -1
View File
@@ -135,6 +135,70 @@ export type CanvasAgentOp =
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
// ---------------------------------------------------------------------------
// AI 生成:插件直接复用宿主的模型/密钥配置发起生成(生图/生视频/生文本/生音频)
//
// 插件本身拿不到 API Key 与模型配置,这些能力由宿主注入。前置/系统提示词由
// 插件自行拼进 prompt(宿主不感知),因此不同插件可各自定制自己的提示词策略。
// 若宿主 AI 配置未就绪,会抛错(并由宿主提示用户去配置),插件用 try/catch 处理即可。
// ---------------------------------------------------------------------------
// 生成公共可选项;references 为图生图/图生视频的参考图(dataURL 或可访问 URL)
export type GenerateOptions = {
signal?: AbortSignal;
references?: string[];
model?: string; // 指定模型(取自 ai.listModels 的 value);缺省用宿主当前配置
};
export type GenerateImageOptions = GenerateOptions & {
count?: number; // 期望生成张数(宿主会按模型上限裁剪)
size?: string; // 形如 "1024x1024" / "auto";缺省用宿主当前配置
};
export type GenerateImageResult = {
// 生成图的 dataURL(已可直接作为 <img src> 或 three 纹理使用)
images: string[];
};
export type GenerateVideoOptions = GenerateOptions & {
size?: string;
seconds?: string;
};
export type GenerateVideoResult = {
url: string; // 视频可访问 URL
mimeType: string;
width?: number;
height?: number;
durationMs?: number;
};
export type GenerateTextOptions = {
signal?: AbortSignal;
model?: string;
system?: string; // 附加系统提示词(拼在宿主系统提示之后)
onDelta?: (text: string) => void; // 流式增量回调
};
export type GenerateTextResult = {
text: string;
};
// 一个可选模型:value 传回给 generateXxx({ model }),label 用于展示
export type ModelCapability = "image" | "video" | "text" | "audio";
export type ModelOption = { value: string; label: string };
// 宿主注入的 AI 生成能力,挂在 ctx.ai 下。任何插件均可调用。
export type CanvasPluginAi = {
generateImage: (prompt: string, options?: GenerateImageOptions) => Promise<GenerateImageResult>;
generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise<GenerateVideoResult>;
generateText: (prompt: string, options?: GenerateTextOptions) => Promise<GenerateTextResult>;
// 列出某能力下用户已配置的可选模型;不传能力则返回全部
listModels: (capability?: ModelCapability) => ModelOption[];
// 该能力当前默认选中的模型 value(可作为下拉框初始值)
defaultModel: (capability: ModelCapability) => string;
};
// ---------------------------------------------------------------------------
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
// ---------------------------------------------------------------------------
@@ -150,6 +214,7 @@ export type CanvasNodeContext = {
node: CanvasNodeData;
theme: CanvasTheme;
scale: number;
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
updateMetadata: (patch: CanvasNodeMetadata) => void;
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
// 图访问
@@ -163,6 +228,11 @@ export type CanvasNodeContext = {
// 节点间/插件间通信
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
ai: CanvasPluginAi;
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
openPanel: () => void;
closePanel: () => void;
// 插件私有持久化,按插件 id 命名空间隔离
storage: PluginStorage;
};
@@ -184,6 +254,16 @@ export type CanvasNodeToolbarItem = {
export type CanvasNodeContentProps = { ctx: CanvasNodeContext };
export type CanvasNodePanelProps = { ctx: CanvasNodeContext; onClose: () => void };
// 复用宿主内置生成面板(与图片/视频/文本节点同一个组件:模型选择、参数设置、
// 提示词库、运行/停止状态全部一致)。声明它即可获得完整生成体验,无需自写面板。
export type CanvasBuiltinPanelConfig = {
mode: "image" | "video" | "text" | "audio"; // 生成类型,决定面板里的模型/设置项
// 提交给模型前自动拼在用户提示词前面的固定前缀(如全景图的 equirectangular 约束)
promptPrefix?: string;
// true(默认)时生成结果写回本节点自身 metadata.content;false 则按内置逻辑生成到下游新节点
writeBackToSelf?: boolean;
};
export type CanvasNodeDefinition = {
type: string; // 建议 "<pluginId>:<name>",全局唯一
title: string;
@@ -194,11 +274,24 @@ export type CanvasNodeDefinition = {
minimapColor?: string;
showInCreateMenu?: boolean; // 默认 true
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
// 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图);选中时仍显示选中描边
transparentBackground?: boolean;
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
// 复用宿主内置生成面板;与自定义 Panel 二选一(同时提供时优先 Panel)
useBuiltinPanel?: CanvasBuiltinPanelConfig;
// 为 true 时:宿主自动在工具条加「交互 ⇄ 移动」开关,并按 metadata.interactive 控制内容层指针事件。
// 默认(interactive 未设/为 false)为「移动」态:内容不吃指针,拖动整块移动节点;
// 切到「交互」态后内容可点击/拖拽(如全景转视角、iframe 操作)。适合内部有交互的展示型节点。
interactionToggle?: boolean;
// 配合 interactionToggle:返回 true 表示当前节点内容「强制可交互」(如编辑态),
// 此时忽略 interactive 标志、始终允许操作,并隐藏移动/交互开关。缺省视为 false。
forceInteractive?: (node: CanvasNodeData) => boolean;
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染
Content?: ComponentType<CanvasNodeContentProps>;
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板(自定义)
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-sticky-note",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas 便利贴节点插件",
+126 -41
View File
@@ -1,55 +1,139 @@
// 便利贴节点:可换色、可编辑、可衍生文本节点(演示 ctx.applyOps 画布指令集)
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
// 便利贴节点:纯展示便利贴——整块可拖动、双击编辑、右上角自选颜色
// 不再声明 resource(避免宿主在右上角显示「文本N」资源角标),也不再衍生节点。
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
const COLORS = ["#fde68a", "#fca5a5", "#a7f3d0", "#bfdbfe", "#ddd6fe"];
// 预设便签色(点选切换),并额外提供自定义取色
const PRESET_COLORS = ["#fde68a", "#fca5a5", "#fdba74", "#a7f3d0", "#bfdbfe", "#ddd6fe", "#f9a8d4", "#e7e5e4"];
const DEFAULT_COLOR = PRESET_COLORS[0];
function StickyNoteContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
// pluginColor 是插件自定义 metadata 字段(非内置字段),读出为 unknown,按需断言
const color = (ctx.node.metadata?.pluginColor as string | undefined) || COLORS[0];
const content = ctx.node.metadata?.content || "";
const [paletteOpen, setPaletteOpen] = useState(false);
// 取色时的本地预览色:仅本组件即时重渲染,避免每次都写宿主 store
const [draftColor, setDraftColor] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const commitTimerRef = useRef<number | null>(null);
const cycleColor = () => {
const next = COLORS[(COLORS.indexOf(color) + 1) % COLORS.length];
ctx.updateMetadata({ pluginColor: next });
// pluginColor 是插件自定义 metadata 字段(读出为 unknown,按需断言)
const committedColor = (ctx.node.metadata?.pluginColor as string | undefined) || DEFAULT_COLOR;
const color = draftColor ?? committedColor;
const content = (ctx.node.metadata?.content as string | undefined) || "";
// 点击便利贴外部时:退出编辑并收起调色板。
// 监听 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上 preventDefault(会抑制
// 兼容 mousedown 事件),所以必须用 pointerdown;capture 阶段又能先于宿主的 stopPropagation
// 触发,避免「按 Esc 能退出、点别处退不出」。
useEffect(() => {
if (!editing && !paletteOpen) return;
const onDocDown = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setEditing(false);
setPaletteOpen(false);
}
};
document.addEventListener("pointerdown", onDocDown, true);
return () => document.removeEventListener("pointerdown", onDocDown, true);
}, [editing, paletteOpen]);
const pickColor = (next: string) => ctx.updateMetadata({ pluginColor: next });
// 连续取色(系统取色器拖动时 onChange 高频触发)会不停调用 updateMetadata,
// 而宿主每次都会整表重建 + 全画布重渲染 + 持久化,导致卡顿。
// 这里先本地预览(setDraftColor),再节流提交到宿主。
const previewColor = (next: string) => {
setDraftColor(next);
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = window.setTimeout(() => {
commitTimerRef.current = null;
pickColor(next);
}, 150);
};
const spawnTextNode = () => {
const id = `sticky-${ctx.node.id}-${ctx.getNodes().length}`;
ctx.applyOps([
{
type: "add_node",
id,
nodeType: "text",
title: "便利贴衍生",
x: ctx.node.position?.x ?? 0,
y: (ctx.node.position?.y ?? 0) + 260,
metadata: { content, status: "success" },
},
{ type: "connect_nodes", fromNodeId: ctx.node.id, toNodeId: id },
]);
// 立即提交(点击预设色、取色器关闭时用):清掉待提交的节流并写入。
const commitColor = (next: string) => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
setDraftColor(next);
pickColor(next);
};
const btn = { width: 26, height: 26, borderRadius: 8, border: "none", cursor: "pointer", background: "rgba(0,0,0,.08)", fontSize: 13 } as const;
useEffect(() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
}, []);
// 宿主里的颜色一旦真正变化(提交完成 / 撤销重做),清掉本地预览,回到 store 为准。
// 拖动取色期间 committedColor 不变,draftColor 得以保留,故不影响即时预览。
useEffect(() => {
setDraftColor(null);
}, [committedColor]);
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
return (
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", background: color, borderRadius: 16, padding: 12, boxSizing: "border-box" }}>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end", marginBottom: 6 }}>
<button type="button" title="换颜色" onClick={cycleColor} style={btn}>
🎨
</button>
<button type="button" title="衍生文本节点" onClick={spawnTextNode} style={btn}>
</button>
<button type="button" title={editing ? "完成" : "编辑"} onClick={() => setEditing((v) => !v)} style={btn}>
{editing ? "✓" : "✎"}
</button>
<div
ref={rootRef}
data-canvas-no-zoom
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", background: color, borderRadius: 16, padding: 14, boxSizing: "border-box", cursor: editing ? "text" : "move", boxShadow: "inset 0 1px 0 rgba(255,255,255,.45)" }}
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
{/* 右上角:当前颜色小圆点,点开后自选颜色(预设 + 自定义) */}
<div style={{ position: "absolute", top: 8, right: 8, zIndex: 5 }} onMouseDown={stop} onDoubleClick={stop}>
<button
type="button"
title="选择颜色"
onClick={() => setPaletteOpen((v) => !v)}
style={{ width: 22, height: 22, borderRadius: "50%", border: "2px solid rgba(0,0,0,.25)", background: color, cursor: "pointer", padding: 0, boxShadow: "0 1px 3px rgba(0,0,0,.2)" }}
/>
{paletteOpen ? (
<div style={{ position: "absolute", top: 28, right: 0, display: "grid", gridTemplateColumns: "repeat(4, 20px)", gap: 6, padding: 8, borderRadius: 12, background: "#fff", boxShadow: "0 8px 24px rgba(0,0,0,.18)" }}>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
title={c}
onClick={() => {
commitColor(c);
setPaletteOpen(false);
}}
style={{ width: 20, height: 20, borderRadius: "50%", border: c === color ? "2px solid #1c1917" : "2px solid rgba(0,0,0,.12)", background: c, cursor: "pointer", padding: 0 }}
/>
))}
{/* 自定义取色:点击彩环唤起系统取色器。拖动时本地预览 + 节流提交,松开(blur)时立即提交 */}
<label title="自定义颜色" style={{ position: "relative", width: 20, height: 20, borderRadius: "50%", cursor: "pointer", background: "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)", border: "2px solid rgba(0,0,0,.12)", boxSizing: "border-box" }}>
<input type="color" value={color} onChange={(e) => previewColor(e.target.value)} onBlur={(e) => commitColor(e.target.value)} style={{ position: "absolute", inset: 0, opacity: 0, width: "100%", height: "100%", border: "none", padding: 0, cursor: "pointer" }} />
</label>
</div>
) : null}
</div>
{/* 内容区:双击进入编辑;非编辑态整块可直接拖动移动节点 */}
{editing ? (
<textarea autoFocus value={content} onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ flex: 1, resize: "none", border: "none", outline: "none", background: "transparent", color: "#1c1917", fontSize: 15, lineHeight: 1.5 }} />
<textarea
autoFocus
value={content}
placeholder="输入便利贴内容…(点击别处或按 Esc 退出编辑)"
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
onBlur={() => setEditing(false)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setEditing(false);
}
}}
onMouseDown={stop}
onPointerDown={stop}
onWheel={stop}
style={{ flex: 1, width: "100%", resize: "none", border: "none", outline: "none", background: "transparent", color: "#1c1917", fontSize: 15, lineHeight: 1.5, fontFamily: "inherit" }}
/>
) : (
<div style={{ flex: 1, whiteSpace: "pre-wrap", overflow: "auto", color: "#1c1917", fontSize: 15, lineHeight: 1.5 }}>{content || "双击编辑便利贴"}</div>
<div style={{ flex: 1, whiteSpace: "pre-wrap", overflow: "hidden", color: content ? "#1c1917" : "rgba(28,25,23,.45)", fontSize: 15, lineHeight: 1.5, userSelect: "none", paddingRight: 22 }}>{content || "双击编辑便利贴"}</div>
)}
</div>
);
@@ -58,8 +142,8 @@ function StickyNoteContent({ ctx }: CanvasNodeContentProps) {
export default definePlugin({
id: "sticky-note",
name: "便利贴节点",
version: "1.0.0",
description: "可换色、可编辑、可衍生文本节点的便利贴",
version: "1.1.0",
description: "可自选颜色、双击编辑、拖动即可移动的便利贴",
nodes: [
{
type: "sticky-note:note",
@@ -67,9 +151,10 @@ export default definePlugin({
icon: "📌",
description: "彩色便利贴",
defaultSize: { width: 240, height: 200 },
defaultMetadata: { content: "", pluginColor: "#fde68a" },
defaultMetadata: { content: "", pluginColor: DEFAULT_COLOR },
minimapColor: "#f59e0b",
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
// 纯记事节点:不弹出下方生成面板(默认会是「生成图片」的提示词面板)
hidePanel: true,
Content: StickyNoteContent,
},
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-svg",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas SVG 节点插件",
+56 -13
View File
@@ -1,34 +1,75 @@
// SVG 节点:编辑与渲染 SVG,无自身内容时可取上游文本节点里的 SVG 源码。
import { definePlugin, useEffect, useState } from "@infinite-canvas/plugin-sdk";
// SVG 节点:编辑与渲染 SVG,透明背景直接融入画布;无自身内容时可取上游文本节点里的 SVG 源码。
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
const DEFAULT_SVG = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40" fill="#6366f1"/></svg>';
function SvgContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
const stored = ctx.node.metadata?.content;
const rootRef = useRef<HTMLDivElement>(null);
const stored = ctx.node.metadata?.content as string | undefined;
const upstream = ctx
.getUpstream()
.map((node) => node.metadata?.content)
.find((text) => text?.trim().startsWith("<svg"));
.find((text): text is string => typeof text === "string" && text.trim().startsWith("<svg"));
const value = stored ?? "";
const svg = value.trim() || upstream || DEFAULT_SVG;
const svg = value.trim() || upstream || "";
// 无自身内容但有上游 SVG:自动采用上游源码
useEffect(() => {
if (stored === undefined && upstream) ctx.updateMetadata({ content: upstream });
}, [ctx, stored, upstream]);
// 点击节点外部时退出编辑。用 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上
// preventDefault 会抑制 mousedown,capture 又能先于宿主 stopPropagation 触发。
useEffect(() => {
if (!editing) return;
const onDocDown = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setEditing(false);
};
document.addEventListener("pointerdown", onDocDown, true);
return () => document.removeEventListener("pointerdown", onDocDown, true);
}, [editing]);
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
const toggle = { position: "absolute", right: 8, top: 8, zIndex: 20, width: 32, height: 32, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: `${ctx.theme.toolbar.panel}dd`, color: ctx.theme.node.text, cursor: "pointer" } as const;
return (
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
<div
ref={rootRef}
data-canvas-no-zoom
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", cursor: editing ? "text" : "move" }}
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
<button type="button" style={toggle} onMouseDown={stop} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
{editing ? "👁" : "✎"}
</button>
{editing ? (
<textarea autoFocus value={value} placeholder={DEFAULT_SVG} onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 12, outline: "none", border: "none", color: ctx.theme.node.text }} />
<textarea
autoFocus
value={value}
placeholder="粘贴 SVG 源码,如 <svg …>…</svg>"
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setEditing(false);
}
}}
onMouseDown={stop}
onPointerDown={stop}
onWheel={stop}
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, padding: 16, boxSizing: "border-box", fontFamily: "monospace", fontSize: 12, outline: "none", border: `1px solid ${ctx.theme.node.stroke}`, color: ctx.theme.node.text }}
/>
) : svg ? (
// pointerEvents:none 让整块可拖动移动,双击/拖拽都命中外层节点
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 12, boxSizing: "border-box", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: svg }} />
) : (
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }} dangerouslySetInnerHTML={{ __html: svg }} />
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, boxSizing: "border-box", color: ctx.theme.node.placeholder, fontSize: 13, textAlign: "center", pointerEvents: "none" }}>, SVG </div>
)}
</div>
);
@@ -37,8 +78,8 @@ function SvgContent({ ctx }: CanvasNodeContentProps) {
export default definePlugin({
id: "svg",
name: "SVG 节点",
version: "1.0.0",
description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码",
version: "1.1.0",
description: "透明背景渲染 SVG 矢量图,可接收上游文本节点的 SVG 源码",
nodes: [
{
type: "svg:vector",
@@ -48,6 +89,8 @@ export default definePlugin({
defaultSize: { width: 320, height: 320 },
defaultMetadata: {},
minimapColor: "#14b8a6",
// 背景/边框透明,矢量图直接融入画布
transparentBackground: true,
Content: SvgContent,
},
],
@@ -25,13 +25,14 @@ type CanvasNodePromptPanelProps = {
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const mode = modeOverride ?? defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
+15 -4
View File
@@ -41,6 +41,7 @@ type CanvasNodeProps = {
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
@@ -102,6 +103,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchRecovering = false,
batchMotion,
onMouseDown,
onSelectCapture,
onHoverStart,
onHoverEnd,
onConnectStart,
@@ -118,7 +120,7 @@ export const CanvasNode = React.memo(function CanvasNode({
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 pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
@@ -127,7 +129,14 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
// 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
// forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
// 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
const transparentBg = Boolean(definition?.transparentBackground);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -301,6 +310,7 @@ export const CanvasNode = React.memo(function CanvasNode({
setHovered(false);
onHoverEnd(data.id);
}}
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
@@ -340,8 +350,8 @@ export const CanvasNode = React.memo(function CanvasNode({
<div
className="relative h-full w-full overflow-visible rounded-3xl border-2"
style={{
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke,
borderStyle: isGroup ? "dashed" : "solid",
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
@@ -370,7 +380,8 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
pointerEvents: contentInteractive ? undefined : "none",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -4,7 +4,7 @@ import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react
import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
import { fetchOfficialPlugins, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { fetchOfficialPlugins, hasUpgrade, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
@@ -81,12 +81,20 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
};
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
const installedControls = (record: InstalledPlugin) => (
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
const installedControls = (record: InstalledPlugin, upgradable = false) => (
<>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Button
type={upgradable ? "primary" : "text"}
size="small"
icon={<RefreshCw className="size-4" />}
loading={busyId === record.id}
title={upgradable ? "有新版本,点击升级" : "从来源更新"}
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")}
/>
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
@@ -95,6 +103,15 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
</>
);
// 图标外挂一个绿点(右上角),用于「有可升级版本」的提示。
// boxShadow 画一圈与卡片同色的描边环,让绿点从图标上「浮起」。
const withUpgradeDot = (icon: ReactNode) => (
<span className="relative inline-flex">
{icon}
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title="有新版本可升级" />
</span>
);
const versionTag = (version: string) => (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{version}
@@ -150,14 +167,18 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => {
const record = recordById.get(entry.id);
// 已安装且远程版本更高 → 显示绿点并高亮升级按钮
const upgradable = Boolean(record && hasUpgrade(record.version, entry.version));
const icon = entry.icon || <Puzzle className="size-4" />;
return row(
entry.id,
entry.icon || <Puzzle className="size-4" />,
upgradable ? withUpgradeDot(icon) : icon,
entry.name,
entry.version,
// 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
upgradable && record ? `${record.version}${entry.version}` : entry.version,
entry.description,
record ? (
installedControls(record)
installedControls(record, upgradable)
) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
+20 -7
View File
@@ -55,9 +55,11 @@ function withCacheBust(url: string) {
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
}
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) {
const source = await fetchPluginSource(url);
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
// bustCache=true 时下载绕过 HTTP/CDN 缓存(升级场景必需,避免拿到旧产物),
// 但落库的 url 始终保持干净(不带 ?t=),便于后续再次更新。
export async function installPluginFromUrl(url: string, opts?: { official?: boolean; bustCache?: boolean }) {
const source = await fetchPluginSource(opts?.bustCache ? withCacheBust(url) : 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, official: opts?.official });
@@ -66,7 +68,8 @@ export async function installPluginFromUrl(url: string, opts?: { official?: bool
}
export async function updatePlugin(record: InstalledPlugin) {
return installPluginFromUrl(record.url, { official: record.official });
// 升级必须拿到最新产物,强制绕过缓存
return installPluginFromUrl(record.url, { official: record.official, bustCache: true });
}
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
@@ -111,7 +114,8 @@ export async function ensurePluginsLoaded() {
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
// 已在列表中的:刷新元数据(version/name/description/source)到最新产物,
// 但保留用户的 enabled 开关 —— 否则改了插件版本后,持久化 store 里的旧 version 永不更新。
async function loadLocalPlugins() {
let urls: unknown;
try {
@@ -128,8 +132,17 @@ async function loadLocalPlugins() {
try {
const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source);
if (store.plugins.some((item) => item.id === plugin.id)) return;
store.upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: false, local: true });
const existing = store.plugins.find((item) => item.id === plugin.id);
store.upsert({
id: plugin.id,
name: plugin.name || plugin.id,
version: plugin.version || "0.0.0",
description: plugin.description,
url,
source,
enabled: existing?.enabled ?? false, // 保留用户开关,新发现默认关闭
local: true,
});
} catch (error) {
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
}
+5 -1
View File
@@ -5,12 +5,13 @@ 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 {
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number, isSelected = false): CanvasNodeContext {
const storage = createPluginStorage(getNodePluginId(node.type));
return {
node,
theme,
scale,
isSelected,
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
updateNode: (patch) => host.updateNode(node.id, patch),
getNode: (id) => host.getNode(id),
@@ -21,6 +22,9 @@ export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, t
applyOps: (ops) => host.applyOps(ops),
emit: (event, payload) => emitCanvasEvent(event, payload),
on: (event, handler) => onCanvasEvent(event, handler),
ai: host.ai,
openPanel: () => host.openPanel(node.id),
closePanel: () => host.closePanel(),
storage,
};
}
+17
View File
@@ -30,3 +30,20 @@ export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
}));
}
// 语义化版本比较:返回 >0 表示 a 更高,<0 表示 b 更高,0 表示相等。
// 只按 major.minor.patch 数值比较,忽略非数字段(预发布标签等)。
function compareSemver(a: string, b: string): number {
const parse = (v: string) => v.split(".").map((part) => parseInt(part, 10) || 0);
const [pa, pb] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
// 远程版本是否比本地已安装版本更高(即有可升级的更新)
export function hasUpgrade(installedVersion: string, remoteVersion: string): boolean {
return compareSemver(remoteVersion, installedVersion) > 0;
}
+234 -82
View File
@@ -4,11 +4,11 @@ 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 { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { DOCS_URL } from "@/constant/env";
import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { nanoid } from "nanoid";
@@ -43,13 +43,13 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
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 { buildCanvasResourceReferences, buildNodeMentionReferences, type CanvasResourceReference } 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 type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
import {
CanvasNodeType,
type CanvasAssistantImage,
@@ -102,6 +102,8 @@ type CanvasGenerationRequest = {
const VIDEO_NODE_MAX_WIDTH = 420;
const VIDEO_NODE_MAX_HEIGHT = 420;
// 稳定的空引用数组:避免每次渲染 `... || []` 产生新数组引用而击穿 CanvasNode 的 React.memo
const EMPTY_REFERENCES: CanvasResourceReference[] = [];
const CONNECTION_HANDLE_HIT_RADIUS = 40;
const CONNECTION_NODE_HIT_PADDING = 32;
const NODE_STATUS_IDLE = "idle" as const;
@@ -688,7 +690,10 @@ function InfiniteCanvasPage() {
}, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
// 工具条跟随「单选节点」:点击/新建/框选/键盘选中任一节点都会显示,不再仅靠精确点中触发。
// 多选时不显示;拖拽中由下方 isNodeDragging 守卫隐藏。
const singleSelectedNodeId = selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null;
const toolbarNode = (toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null) || (singleSelectedNodeId ? nodeById.get(singleSelectedNodeId) || null : null);
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
@@ -816,6 +821,47 @@ function InfiniteCanvasPage() {
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
// 提供给插件节点的宿主能力(节点无关,方法接收 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,
@@ -826,8 +872,11 @@ function InfiniteCanvasPage() {
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],
[applyAgentOps, pluginAi],
);
const renderPluginPanel = useCallback(
@@ -840,6 +889,28 @@ function InfiniteCanvasPage() {
[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();
@@ -861,7 +932,17 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(new Set([newNode.id]));
setSelectedConnectionId(null);
const definition = getNodeDefinition(type);
if (definition?.Panel || (isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group)) setDialogNodeId(newNode.id);
// 纯展示型插件节点(hidePanel)不弹面板;插件自定义 Panel 需显式 autoOpenPanel 才在新建时打开;
// 声明了 useBuiltinPanel 的插件节点复用内置生成面板,新建即打开(与图片节点一致);
// 内置的图片/视频/配置类节点保持原有「新建即打开生图面板」行为。
const wantsPanel = definition?.hidePanel
? false
: definition?.Panel
? Boolean(definition.autoOpenPanel)
: definition?.useBuiltinPanel
? true
: isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group;
if (wantsPanel) setDialogNodeId(newNode.id);
},
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
);
@@ -1161,30 +1242,42 @@ function InfiniteCanvasPage() {
[cancelPendingConnectionCreate, screenToCanvas],
);
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
setContextMenu(null);
setHoveredNodeId(null);
setToolbarNodeId(null);
setSelectedConnectionId(null);
const currentSelected = selectedNodeIdsRef.current;
const currentNodes = nodesRef.current;
const nextSelected = new Set(currentSelected);
// 仅处理「选中」的纯逻辑,供 body 冒泡拖拽入口与外层 capture 入口共用。
// 返回本次点击后的单选目标 id(多选/取消时为 null),用于同步工具条。
const selectNodeByEvent = useCallback((event: Pick<ReactMouseEvent, "shiftKey" | "metaKey" | "ctrlKey">, nodeId: string) => {
const nextSelected = new Set(selectedNodeIdsRef.current);
if (event.shiftKey || event.metaKey || event.ctrlKey) {
if (nextSelected.has(nodeId)) {
nextSelected.delete(nodeId);
} else {
nextSelected.add(nodeId);
}
if (nextSelected.has(nodeId)) nextSelected.delete(nodeId);
else nextSelected.add(nodeId);
} else if (!nextSelected.has(nodeId)) {
nextSelected.clear();
nextSelected.add(nodeId);
}
setSelectedNodeIds(nextSelected);
setToolbarNodeId(nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null);
const soloId = nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null;
setToolbarNodeId(soloId);
return { nextSelected, soloId };
}, []);
// capture 阶段选中:点击节点内部任意元素(含吞掉 mousedown 的 textarea/iframe)都能选中并弹出工具条。
// 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。
// capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。
const pendingSelectionRef = useRef<Set<string> | null>(null);
const handleNodeSelectCapture = useCallback((event: ReactMouseEvent, nodeId: string) => {
if (event.button !== 0) return;
setContextMenu(null);
setHoveredNodeId(null);
setSelectedConnectionId(null);
const { nextSelected } = selectNodeByEvent(event, nodeId);
pendingSelectionRef.current = nextSelected;
}, [selectNodeByEvent]);
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
// 选中已由 capture 阶段完成;这里只负责建立拖拽。若因故没走 capture,则兜底再选一次。
const currentNodes = nodesRef.current;
const nextSelected = pendingSelectionRef.current ?? selectNodeByEvent(event, nodeId).nextSelected;
pendingSelectionRef.current = null;
const dragIds = new Set(nextSelected);
currentNodes.forEach((node) => {
if (!nextSelected.has(node.id)) return;
@@ -1248,8 +1341,12 @@ function InfiniteCanvasPage() {
dragRef.current.initialSelectedNodes = [];
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined;
if (clickedNode?.type === CanvasNodeType.Text) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedDefinition?.hidePanel) {
// 纯展示型插件节点:单击只选中,不弹下方面板
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedNode?.type !== CanvasNodeType.Group) {
setDialogNodeId(clickedNodeId);
}
@@ -2058,6 +2155,45 @@ function InfiniteCanvasPage() {
return;
}
// 插件节点声明了 useBuiltinPanel.writeBackToSelf:复用内置面板生成,但结果写回节点自身。
// 目前支持 image 模式(全景等展示型节点),前缀由 useBuiltinPanel.promptPrefix 指定。
const builtinPanel = sourceNode ? getNodeDefinition(sourceNode.type)?.useBuiltinPanel : undefined;
if (sourceNode && builtinPanel?.writeBackToSelf && builtinPanel.mode === "image") {
const scene = prompt.trim();
if (!scene) return;
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: scene, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
try {
const fullPrompt = (builtinPanel.promptPrefix || "") + scene;
// 上游图片节点作为参考图(图生图);无上游则纯文生图
const upstreamNodes = connectionsRef.current
.filter((conn) => conn.toNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node));
const refs = upstreamNodes.flatMap((up) =>
typeof up.metadata?.content === "string" && up.metadata.content && up.type !== sourceNode.type
? [{ id: up.id, name: `${up.title || up.id}.png`, type: up.metadata.mimeType || "image/png", dataUrl: up.metadata.content, storageKey: up.metadata.storageKey }]
: [],
);
const image = refs.length
? await requestEdit({ ...generationConfig, count: "1" }, fullPrompt, refs, undefined, { signal: controller.signal }).then((items) => items[0])
: await requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)));
setDialogNodeId(null);
} catch (error) {
if (!isGenerationCanceled(error)) {
const errorDetails = error instanceof Error ? error.message : "生成失败";
message.error(errorDetails);
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
}
} finally {
finishGenerationRequest(nodeId, controller);
}
return;
}
setRunningNodeId(nodeId);
const runController = startGenerationRequest(nodeId, nodeId, nodeId);
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
@@ -2567,6 +2703,68 @@ function InfiniteCanvasPage() {
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
);
// --- 传给 CanvasNode 的回调/渲染函数统一 memo 化 ---
// CanvasNode 是 React.memo,但只要这些 prop 每次渲染都是新引用,memo 就失效,
// 导致点击/悬停/移动视角时全部节点跟着重渲染(markdown 尤其明显)。全部 useCallback 后,
// 未变化的节点不再重渲染。依赖里的 map/handler 均已 memo 化,纯交互时保持稳定。
const handleNodeHoverStart = useCallback((nodeId: string) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
}, []);
const handleNodeHoverEnd = useCallback((nodeId: string) => {
setHoveredNodeId((current) => (current === nodeId ? null : current));
}, []);
const handleNodeViewImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
const handleNodeRetry = useCallback((node: CanvasNodeData) => void handleRetryNode(node), [handleRetryNode]);
const handleNodeContextMenu = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId });
}, []);
const renderNodePanel = useCallback(
(panelNode: CanvasNodeData) =>
getNodeDefinition(panelNode.type)?.Panel ? (
renderPluginPanel(panelNode)
) : panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""} inputs={configInputsById.get(panelNode.id) || []} onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })} onClose={() => setDialogNodeId(null)} />
) : (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || EMPTY_REFERENCES}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onStop={confirmStopGeneration}
modeOverride={getNodeDefinition(panelNode.type)?.useBuiltinPanel?.mode}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
),
[configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, renderPluginPanel, runningNodeId],
);
const renderNodeContentPanel = useCallback(
(contentNode: CanvasNodeData) => (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
onConfigChange={handleConfigNodeChange}
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
onStop={confirmStopGeneration}
onGenerate={(nodeId) => {
const target = nodesRef.current.find((item) => item.id === nodeId);
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
}}
/>
),
[configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, runningNodeId],
);
if (!projectLoaded) return <CanvasRefreshShell />;
return (
@@ -2659,7 +2857,7 @@ function InfiniteCanvasPage() {
isConnectionTarget={connectionTargetNodeId === node.id}
isConnecting={Boolean(connectingParams)}
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
showPanel={dialogNodeId === node.id && !selectionBox}
showPanel={dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel}
batchCount={batchChildCountById.get(node.id) || 0}
groupChildCount={groupChildCountById.get(node.id) || 0}
isGroupDropTarget={dropTargetGroupId === node.id}
@@ -2670,71 +2868,25 @@ function InfiniteCanvasPage() {
batchMotion={batchMotionById.get(node.id)}
showImageInfo={showImageInfo}
resourceLabel={resourceReferenceByNodeId.get(node.id)}
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
mentionReferences={mentionReferencesByNodeId.get(node.id) || EMPTY_REFERENCES}
pluginHost={pluginHost}
registryVersion={nodeRegistryVersion}
renderPanel={(panelNode) =>
getNodeDefinition(panelNode.type)?.Panel ? (
renderPluginPanel(panelNode)
) : panelNode.type === CanvasNodeType.Config ? (
<CanvasConfigComposer
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
inputs={configInputsById.get(panelNode.id) || []}
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
onClose={() => setDialogNodeId(null)}
/>
) : (
<CanvasNodePromptPanel
node={panelNode}
isRunning={runningNodeId === panelNode.id}
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
onPromptChange={handleNodePromptChange}
onConfigChange={handleConfigNodeChange}
onGenerate={handleGenerateNode}
onStop={confirmStopGeneration}
onImageSettingsOpenChange={(open) => {
setNodeImageSettingsOpen(open);
if (open) setToolbarNodeId(null);
}}
/>
)
}
renderNodeContent={(contentNode) => (
<CanvasConfigNodePanel
node={contentNode}
isRunning={runningNodeId === contentNode.id}
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
onConfigChange={handleConfigNodeChange}
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
onStop={confirmStopGeneration}
onGenerate={(nodeId) => {
const target = nodesRef.current.find((item) => item.id === nodeId);
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
}}
/>
)}
renderPanel={renderNodePanel}
renderNodeContent={renderNodeContentPanel}
onMouseDown={handleNodeMouseDown}
onHoverStart={(nodeId) => {
if (nodeDraggingRef.current) return;
setHoveredNodeId(nodeId);
}}
onHoverEnd={(nodeId) => {
setHoveredNodeId((current) => (current === nodeId ? null : current));
}}
onSelectCapture={handleNodeSelectCapture}
onHoverStart={handleNodeHoverStart}
onHoverEnd={handleNodeHoverEnd}
onConnectStart={handleConnectStart}
onResize={handleNodeResize}
onContentChange={handleNodeContentChange}
onTitleChange={handleNodeTitleChange}
onToggleBatch={toggleBatchExpanded}
onSetBatchPrimary={setBatchPrimary}
onRetry={(node) => void handleRetryNode(node)}
onRetry={handleNodeRetry}
onGenerateImage={generateImageFromTextNode}
onViewImage={(node) => setPreviewNodeId(node.id)}
onContextMenu={(event, id) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id });
}}
onViewImage={handleNodeViewImage}
onContextMenu={handleNodeContextMenu}
/>
))}
@@ -2767,7 +2919,7 @@ function InfiniteCanvasPage() {
<CanvasNodeHoverToolbar
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
viewport={viewport}
extraTools={toolbarNode ? getNodeDefinition(toolbarNode.type)?.toolbar?.(buildNodeContext(pluginHost, toolbarNode, theme, viewport.k)) : undefined}
extraTools={toolbarNode ? buildNodeToolbarItems(toolbarNode) : undefined}
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)}
+45
View File
@@ -8,6 +8,25 @@ import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references
// 插件节点作为上游输入被消费时输出的资源
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
// --- AI 生成能力(生图/生视频/生文本),由宿主注入,复用宿主模型/密钥配置 ---
export type GenerateOptions = { signal?: AbortSignal; references?: string[]; model?: string };
export type GenerateImageOptions = GenerateOptions & { count?: number; size?: string };
export type GenerateImageResult = { images: string[] };
export type GenerateVideoOptions = GenerateOptions & { size?: string; seconds?: string };
export type GenerateVideoResult = { url: string; mimeType: string; width?: number; height?: number; durationMs?: number };
export type GenerateTextOptions = { signal?: AbortSignal; model?: string; system?: string; onDelta?: (text: string) => void };
export type GenerateTextResult = { text: string };
export type PluginModelCapability = "image" | "video" | "text" | "audio";
export type ModelOption = { value: string; label: string };
export type CanvasPluginAi = {
generateImage: (prompt: string, options?: GenerateImageOptions) => Promise<GenerateImageResult>;
generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise<GenerateVideoResult>;
generateText: (prompt: string, options?: GenerateTextOptions) => Promise<GenerateTextResult>;
listModels: (capability?: PluginModelCapability) => ModelOption[];
defaultModel: (capability: PluginModelCapability) => string;
};
// 节点自带的工具栏按钮(追加到 hover 工具栏尾部)
export type CanvasNodeToolbarItem = {
id: string;
@@ -24,6 +43,7 @@ export type CanvasNodeContext = {
node: CanvasNodeData;
theme: CanvasTheme;
scale: number;
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
// 自身数据
updateMetadata: (patch: CanvasNodeMetadata) => void;
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
@@ -38,6 +58,11 @@ export type CanvasNodeContext = {
// 节点间/插件间通信
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
ai: CanvasPluginAi;
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
openPanel: () => void;
closePanel: () => void;
// 插件私有持久化,命名空间隔离
storage: PluginStorage;
};
@@ -58,6 +83,18 @@ export type CanvasPluginHost = {
updateNode: (nodeId: string, patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
updateMetadata: (nodeId: string, patch: CanvasNodeMetadata) => void;
applyOps: (ops: CanvasAgentOp[]) => void;
// AI 生成能力,复用画布页面当前的模型/密钥配置
ai: CanvasPluginAi;
// 打开/关闭指定节点下方的自定义 Panel
openPanel: (nodeId: string) => void;
closePanel: () => void;
};
// 复用宿主内置生成面板的配置(见 SDK CanvasBuiltinPanelConfig)
export type CanvasBuiltinPanelConfig = {
mode: "image" | "video" | "text" | "audio";
promptPrefix?: string;
writeBackToSelf?: boolean;
};
// 节点类型定义:内置节点与插件节点统一走这套结构
@@ -71,6 +108,14 @@ export type CanvasNodeDefinition = {
minimapColor?: string;
showInCreateMenu?: boolean; // 默认 true
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
transparentBackground?: boolean; // 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图)
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
useBuiltinPanel?: CanvasBuiltinPanelConfig; // 复用宿主内置生成面板(与自定义 Panel 二选一)
// 为 true 时:宿主自动提供「交互 ⇄ 移动」工具条开关,并按 metadata.interactive 控制内容层指针事件
interactionToggle?: boolean;
// 配合 interactionToggle:返回 true 表示内容强制可交互(如编辑态),忽略 interactive 并隐藏开关
forceInteractive?: (node: CanvasNodeData) => boolean;
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
+1
View File
@@ -61,6 +61,7 @@ export type CanvasNodeMetadata = {
bytes?: number;
durationMs?: number;
groupId?: string;
interactive?: boolean; // 插件节点「交互 ⇄ 移动」开关状态(见 CanvasNodeDefinition.interactionToggle)
};
export type CanvasNodeData = {