mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f9776bee3 | |||
| 4beadc6161 | |||
| 13c508bc34 | |||
| 5f627d5baf | |||
| 3f179a607a | |||
| 2d2c7e3d5e | |||
| 3f623ea70c |
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: bun install --frozen-lockfile
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas HTML 节点插件",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas Markdown 节点插件",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 3D 全景节点插件",
|
||||
|
||||
@@ -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: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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/`);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-sticky-note",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 便利贴节点插件",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "canvas-plugin-svg",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas SVG 节点插件",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}>
|
||||
安装
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,7 @@ export type CanvasNodeMetadata = {
|
||||
bytes?: number;
|
||||
durationMs?: number;
|
||||
groupId?: string;
|
||||
interactive?: boolean; // 插件节点「交互 ⇄ 移动」开关状态(见 CanvasNodeDefinition.interactionToggle)
|
||||
};
|
||||
|
||||
export type CanvasNodeData = {
|
||||
|
||||
Reference in New Issue
Block a user