mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Merge branch 'dev'
# Conflicts: # .gitignore # CHANGELOG.md # web/bun.lock
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
name: Publish Official Plugins
|
||||||
|
|
||||||
|
# 构建官方插件并把产物(bundle + 清单)强推到孤儿分支 plugins-dist。
|
||||||
|
# 与 github-pages.yml 一致,在打版本 tag 时触发 —— 发版时 web 与官方插件一起发布。
|
||||||
|
# 前端经 jsDelivr 从该分支远程拉取;构建产物不进 main、不进 git。
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-plugins
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
working-directory: plugins/canvas/registry
|
||||||
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build official plugins
|
||||||
|
working-directory: plugins/canvas/registry
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Publish to plugins-dist branch
|
||||||
|
working-directory: plugins/canvas/registry/dist
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git init -q
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git checkout -q -b plugins-dist
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "publish official plugins @ ${GITHUB_SHA::7}"
|
||||||
|
git push -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" plugins-dist
|
||||||
@@ -12,3 +12,6 @@ web/dist
|
|||||||
|
|
||||||
**/dist
|
**/dist
|
||||||
web/public/plugins
|
web/public/plugins
|
||||||
|
plugins/canvas/*/dist
|
||||||
|
plugins/canvas/*/node_modules
|
||||||
|
web/public/plugins
|
||||||
+9
-1
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
||||||
|
+ [新增] 插件开发 SDK,可用 TypeScript 开发画布节点插件。
|
||||||
|
+ [新增] 新增 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||||
|
+ [新增] 官方插件注册表:节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||||
|
+ [新增] 在画布右上角工具栏新增「节点插件」入口。
|
||||||
|
+ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。
|
||||||
|
|
||||||
|
|
||||||
## v0.7.1 - 2026-07-15
|
## v0.7.1 - 2026-07-15
|
||||||
|
|
||||||
+ [修复] 修复通过 `crypto.randomUUID` 不可用导致页面白屏报错的问题,改用 nanoid 生成 id。
|
+ [修复] 修复通过 `crypto.randomUUID` 不可用导致页面白屏报错的问题,改用 nanoid 生成 id。
|
||||||
@@ -10,7 +18,7 @@
|
|||||||
|
|
||||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
|
||||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||||
|
|||||||
+16
@@ -31,6 +31,22 @@ Please include:
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
### Canvas node plugins
|
||||||
|
|
||||||
|
The canvas supports third-party node plugins loaded from a remote URL. By
|
||||||
|
design, an installed plugin's code runs directly inside the web app with full
|
||||||
|
access to the page, including locally stored data such as AI API keys. This is
|
||||||
|
an intentional trade-off for extensibility, and the installer shows a warning
|
||||||
|
before installing. Therefore:
|
||||||
|
|
||||||
|
- Only install plugins from sources you trust.
|
||||||
|
- Reports that a *malicious plugin* can access page data or API keys are **out
|
||||||
|
of scope** — that is the documented behavior of the trust model.
|
||||||
|
- Reports **in scope** include: the app loading/executing plugin code without
|
||||||
|
the install confirmation, a plugin escaping its declared node type to break
|
||||||
|
core app integrity in ways not implied by "runs in the page", or the plugin
|
||||||
|
source cache being writable by an unrelated origin.
|
||||||
|
|
||||||
Examples of in-scope reports:
|
Examples of in-scope reports:
|
||||||
|
|
||||||
- Cross-site scripting or token exfiltration in the web app.
|
- Cross-site scripting or token exfiltration in the web app.
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
|
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件。
|
||||||
|
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线)。
|
||||||
|
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 {{input}} 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互。
|
||||||
|
- 画布节点插件系统:卸载或禁用插件后,画布上遗留的该类型节点显示「缺少插件」占位且数据保留,重新安装/启用后恢复渲染;需验证刷新后已启用插件自动加载。
|
||||||
|
- 画布节点插件系统:每个插件为 `plugins/canvas/<name>/` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/<name>.js` 并同步到 `web/public/plugins/`;需验证各插件可独立构建与安装。
|
||||||
|
- 画布节点插件本地开发:每个插件支持 `npm run dev`(watch 自动构建并同步到 `web/public/plugins/`),配合 `web/.env.local` 的 `VITE_DEV_PLUGINS`(逗号分隔 URL)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效。
|
||||||
|
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
|
||||||
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
|
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
|
||||||
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
|
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
|
||||||
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
|
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Infinite Canvas 画布节点插件
|
||||||
|
|
||||||
|
给画布扩展自定义节点。每个插件是一个**独立目录**,用 **TypeScript** 编写,自带 `package.json` / `build.mjs` / `src/index.tsx` / `dist/`,互不耦合,可单独构建、发布、升级。
|
||||||
|
|
||||||
|
内置节点只有文本、图片、视频、音频、生成配置、组六种;其余节点(Markdown、SVG、HTML、3D 全景、便利贴……)都是插件。
|
||||||
|
|
||||||
|
作者只写节点 UI 与逻辑,**类型、JSX、宿主 React、构建全部由 [`@infinite-canvas/plugin-sdk`](./sdk/README.md) 提供**,写 TSX 全程有代码提示;产物仍是宿主加载器现有契约的 ESM(React external、宿主单例)。
|
||||||
|
|
||||||
|
## 目录约定
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/canvas/
|
||||||
|
sdk/ # 插件 SDK(类型 + automatic JSX 运行时 + 构建助手)
|
||||||
|
template/ # 起步模板:复制它开始写新插件
|
||||||
|
markdown/ # 每个插件一个独立目录
|
||||||
|
package.json
|
||||||
|
build.mjs # 一行 buildPlugin,产物名取目录名 → dist/markdown.js
|
||||||
|
tsconfig.json
|
||||||
|
src/index.tsx # 插件源码(默认导出 definePlugin(...))
|
||||||
|
README.md
|
||||||
|
svg/ html/ panorama/ sticky-note/ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r plugins/canvas/template plugins/canvas/my-plugin
|
||||||
|
cd plugins/canvas/my-plugin
|
||||||
|
# 改 package.json 的 name;改 src/index.tsx 里的 id / nodes[].type
|
||||||
|
npm install
|
||||||
|
npm run dev # watch 构建,产物同步到 web/public/plugins/my-plugin.js
|
||||||
|
npm run typecheck # tsc --noEmit,类型自检
|
||||||
|
```
|
||||||
|
|
||||||
|
产物名取**目录名**,复制后记得把目录改成插件名。
|
||||||
|
|
||||||
|
## 构建 / 发布 / 升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/canvas/<name>
|
||||||
|
npm install
|
||||||
|
npm run build # → dist/<name>.js,并同步到 web/public/plugins/<name>.js
|
||||||
|
npm run dev # watch,改动自动构建并同步
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `dist/<name>.js` 托管到任意静态地址(CDN、GitHub Raw、对象存储),用户在画布「节点插件」管理器填该 URL 安装。升级时重新构建覆盖同一 URL,用户点「更新」即可。
|
||||||
|
|
||||||
|
## 官方插件注册表
|
||||||
|
|
||||||
|
本项目官方插件由 CI 集中构建后发布到孤儿分支 `plugins-dist`(**构建产物不进 git**),画布「节点插件」面板顶部的**官方插件**区经 jsDelivr 从该分支远程拉取并一键安装;第三方插件仍走下方「第三方插件」的 JS URL 安装。构建脚本与发布说明见 [`registry/`](./registry/README.md);清单地址可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖成自建来源。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
`npm run dev` 起 watch,产物会同步到 `web/public/plugins/<name>.js`。此后有两种方式在画布里用到它:
|
||||||
|
|
||||||
|
**方式一(推荐):自动发现。** 画布启动时会扫描 `web/public/plugins/` 下的插件,自动加入「节点插件」管理器列表,**默认关闭**;打开开关即启用。无需手动填 URL,启用时会按文件重新拉取,配合 watch 改完刷新即最新。
|
||||||
|
|
||||||
|
**方式二:`VITE_DEV_PLUGINS`。** 在 `web/.env.local` 声明(逗号分隔多个),这些插件每次刷新页面都**重新拉取并直接激活**(不缓存、不落库、无开关):
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_DEV_PLUGINS=/plugins/markdown.js,/plugins/svg.js
|
||||||
|
```
|
||||||
|
|
||||||
|
再起画布 `web`(`npm run dev`)。流程即:改 `src/index.tsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。
|
||||||
|
|
||||||
|
## 用 SDK 写插件
|
||||||
|
|
||||||
|
默认导出 `definePlugin({...})`(对象形式,**无需再 `const { React } = runtime`**):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
function Content({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [n, setN] = useState(0);
|
||||||
|
return (
|
||||||
|
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||||
|
{ctx.node.title}: {n}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "my-plugin",
|
||||||
|
name: "我的插件",
|
||||||
|
version: "1.0.0",
|
||||||
|
css: "…", // 可选:插件样式,自动注入/清理
|
||||||
|
nodes: [ /* CanvasNodeDefinition[] */ ],
|
||||||
|
setup(app) { return () => {}; }, // 可选,返回清理函数;app 含 injectCSS/emit/on
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK 导出的 hooks(`useState/useEffect/useMemo/useRef/...`)运行时转发宿主 React,类型来自 `@types/react`。SDK 与依赖接入见 [`sdk/README.md`](./sdk/README.md)。
|
||||||
|
|
||||||
|
### CanvasNodeDefinition
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||||
|
title: string; // 创建菜单/默认标题
|
||||||
|
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||||
|
description?: string;
|
||||||
|
defaultSize: { width, height };
|
||||||
|
defaultMetadata?: object; // 新建节点初始 metadata(文本内容放 content)
|
||||||
|
minimapColor?: string;
|
||||||
|
showInCreateMenu?: boolean; // 默认 true
|
||||||
|
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||||
|
keepAspectRatio?: (node) => boolean;
|
||||||
|
resource?: (node) => { kind: "text"|"image"|"video"|"audio", text?, url? } | null; // 作为上游输入被消费时输出什么
|
||||||
|
Content: ({ ctx }) => ReactNode; // 节点主体渲染
|
||||||
|
Panel?: ({ ctx, onClose }) => ReactNode; // 可选:节点下方面板
|
||||||
|
toolbar?: (ctx) => Array<{ id, title, label, icon, onClick, danger? }>; // 追加到 hover 工具栏
|
||||||
|
onDoubleClick?: (ctx) => boolean; // 返回 true 表示已处理双击
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx:节点与画布交互接口
|
||||||
|
|
||||||
|
`Content` / `Panel` / `toolbar` 都会拿到 `ctx`(类型 `CanvasNodeContext`):
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ctx.node` | 当前节点数据(含 `metadata.content` 等) |
|
||||||
|
| `ctx.theme` / `ctx.scale` | 当前画布主题 token 与缩放,用来让 UI 跟随主题 |
|
||||||
|
| `ctx.updateMetadata(patch)` | 更新自身 metadata(如保存内容) |
|
||||||
|
| `ctx.updateNode(patch)` | 更新自身 title/width/height |
|
||||||
|
| `ctx.getNode(id)` / `ctx.getNodes()` / `ctx.getConnections()` | 读画布 |
|
||||||
|
| `ctx.getUpstream()` / `ctx.getDownstream()` | 取上/下游相连节点 |
|
||||||
|
| `ctx.applyOps(ops)` | 用画布指令集增删节点/连线、选择、触发生成(见下) |
|
||||||
|
| `ctx.emit(event, payload)` / `ctx.on(event, handler)` | 节点/插件间事件通信 |
|
||||||
|
| `ctx.storage` | 插件私有持久化(按插件 id 命名空间) |
|
||||||
|
|
||||||
|
> `metadata` 的**内置字段**(content、status、model…)是强类型;插件写入的**自定义字段**读出为 `unknown`,按需 `as` 断言(参考 `sticky-note` 的 `pluginColor`)。
|
||||||
|
|
||||||
|
### 画布指令集(ctx.applyOps)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.applyOps([
|
||||||
|
{ type: "add_node", id?, nodeType, title?, x?, y?, width?, height?, metadata? },
|
||||||
|
{ type: "update_node", id, patch?, metadata? },
|
||||||
|
{ type: "delete_node", id? | ids? },
|
||||||
|
{ type: "connect_nodes", fromNodeId, toNodeId },
|
||||||
|
{ type: "delete_connections", id? | ids? | all? },
|
||||||
|
{ type: "select_nodes", ids },
|
||||||
|
{ type: "set_viewport", viewport },
|
||||||
|
{ type: "run_generation", nodeId, mode?, prompt? },
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重依赖 / 资源
|
||||||
|
|
||||||
|
- **重依赖**(three.js、marked 等):不要打进 bundle,运行时 `await import("https://esm.sh/...")` 动态加载(esbuild 自动 external);在 `src/env.d.ts` 声明该模块以通过 tsc。参考 `panorama/`、`markdown/`。
|
||||||
|
- **CSS**:写独立 `.css`,`import css from "./styles.css"` 拿到字符串(esbuild `text` loader),放到 `css` 字段自动注入/清理;`src/env.d.ts` 声明 `*.css`。参考 `markdown/`。
|
||||||
|
- **HTML**:HTML 节点把 HTML 字符串塞进 sandbox iframe 的 `srcDoc`,自带 `<style>`,不需要插件级 CSS。参考 `html/`。
|
||||||
|
|
||||||
|
## 兼容说明
|
||||||
|
|
||||||
|
加载器仍接受**默认导出为工厂函数** `(runtime) => CanvasPlugin`(用 `runtime.React`)或**普通对象**;老的 JS 插件无需改动即可运行。SDK 的 automatic JSX 让新插件走对象形式,更简洁。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 插件代码会在画布页面内**直接执行**,可访问浏览器本地数据(含 AI API Key)。发布前请自审,用户也只应安装可信来源。
|
||||||
|
- 交互控件记得 `onMouseDown={(e) => e.stopPropagation()}`(避免触发节点拖拽),滚动区域加 `onWheel={(e) => e.stopPropagation()}` 与容器 `data-canvas-no-zoom`(避免被画布缩放拦截)。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# HTML 节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:用沙箱 iframe 渲染 HTML。源码里的 `{{input}}` 会被替换为上游文本节点内容。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/html.js,并同步到 web/public/plugins/html.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/html.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
Generated
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-html",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-html",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-html",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas HTML 节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
||||||
|
import { definePlugin, useMemo, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const value = ctx.node.metadata?.content || "";
|
||||||
|
const upstreamText = useMemo(
|
||||||
|
() =>
|
||||||
|
ctx
|
||||||
|
.getUpstream()
|
||||||
|
.map((node) => node.metadata?.content)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
[ctx],
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "html",
|
||||||
|
name: "HTML 节点",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "html:render",
|
||||||
|
title: "HTML",
|
||||||
|
icon: "🌐",
|
||||||
|
description: "沙箱渲染 HTML",
|
||||||
|
defaultSize: { width: 420, height: 320 },
|
||||||
|
defaultMetadata: { content: "" },
|
||||||
|
minimapColor: "#ec4899",
|
||||||
|
Content: HtmlContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Markdown 节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:在画布里编辑与渲染 Markdown。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/markdown.js,并同步到 web/public/plugins/markdown.js
|
||||||
|
npm run dev # watch,改动自动构建
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/markdown.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
`npm run dev` 起 watch,在 `web/.env.local` 加 `VITE_DEV_PLUGINS=/plugins/markdown.js`,起画布后改 `src/index.jsx` 刷新页面即生效,无需反复安装。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
|
|
||||||
|
> 本插件演示了独立 CSS 文件用法:`src/styles.css` 经 esbuild `text` loader 打进 bundle,通过插件 `css` 字段自动注入/清理。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-markdown",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-markdown",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-markdown",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas Markdown 节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
// esbuild 以 text loader 把 .css 打成字符串
|
||||||
|
declare module "*.css" {
|
||||||
|
const css: string;
|
||||||
|
export default css;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重依赖从 CDN 动态加载,不打进 bundle;此处声明以便 TS 提示
|
||||||
|
declare module "https://esm.sh/marked@14" {
|
||||||
|
export const marked: { parse: (src: string) => string };
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
|
||||||
|
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
|
||||||
|
import { definePlugin, useEffect, useMemo, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
import css from "./styles.css";
|
||||||
|
|
||||||
|
type Marked = { parse: (src: string) => string };
|
||||||
|
|
||||||
|
let marked: Marked | undefined; // 加载完成后的 marked 实例,模块级缓存,后续同步渲染
|
||||||
|
let markedPromise: Promise<Marked> | undefined;
|
||||||
|
function loadMarked(): Promise<Marked> {
|
||||||
|
if (marked) return Promise.resolve(marked);
|
||||||
|
if (!markedPromise) markedPromise = import("https://esm.sh/marked@14").then((mod: { marked: Marked }) => (marked = mod.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 || "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (marked) return;
|
||||||
|
let alive = true;
|
||||||
|
loadMarked().then(() => alive && setMarkedReady(true));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// marked 就绪后同步计算 HTML(按 value 记忆),不再用异步 setState 填充,消除闪烁
|
||||||
|
const html = useMemo(() => (marked ? marked.parse(value || "*双击右上角按钮编辑 Markdown*") : ""), [value, markedReady]);
|
||||||
|
|
||||||
|
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 ? "预览" : "编辑"}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "markdown",
|
||||||
|
name: "Markdown 节点",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "在画布中编辑与渲染 Markdown",
|
||||||
|
css,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "markdown:doc",
|
||||||
|
title: "Markdown",
|
||||||
|
icon: "📝",
|
||||||
|
description: "编辑与渲染 Markdown",
|
||||||
|
defaultSize: { width: 360, height: 300 },
|
||||||
|
defaultMetadata: { content: "" },
|
||||||
|
minimapColor: "#6366f1",
|
||||||
|
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||||
|
Content: MarkdownContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.cnv-md {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cnv-md h1,
|
||||||
|
.cnv-md h2,
|
||||||
|
.cnv-md h3 {
|
||||||
|
margin: 0.6em 0 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.cnv-md h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.cnv-md h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.cnv-md p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.cnv-md a {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.cnv-md code {
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(120, 120, 120, 0.16);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.cnv-md pre {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(120, 120, 120, 0.14);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.cnv-md pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.cnv-md ul,
|
||||||
|
.cnv-md ol {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.cnv-md blockquote {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 0.8em;
|
||||||
|
border-left: 3px solid rgba(120, 120, 120, 0.4);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.cnv-md img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 3D 全景节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:查看 360° 等距柱状(equirectangular)全景图,可拖拽旋转。可从上游图片节点自动取图。three.js 运行时从 CDN 按需加载,不打进插件体积。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/panorama.js,并同步到 web/public/plugins/panorama.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/panorama.js`(或托管后的公网 URL)。连接一个全景图片节点到本节点即可查看。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-panorama",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-panorama",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-panorama",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 3D 全景节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// three 从 CDN 动态加载,不打进 bundle;声明为宽松类型以便通过 tsc
|
||||||
|
declare module "https://esm.sh/three@0.180.0";
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// 3D 全景节点:等距柱状(equirectangular)全景查看器。three.js 从 CDN 按需加载。
|
||||||
|
import { definePlugin, useEffect, useRef } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
|
||||||
|
let threePromise: Promise<any> | undefined;
|
||||||
|
function loadThree(): Promise<any> {
|
||||||
|
if (!threePromise) threePromise = import("https://esm.sh/three@0.180.0");
|
||||||
|
return threePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const mountRef = useRef<HTMLDivElement>(null);
|
||||||
|
const upstreamImage = ctx
|
||||||
|
.getUpstream()
|
||||||
|
.map((node) => node.metadata?.content)
|
||||||
|
.find(Boolean);
|
||||||
|
const source = ctx.node.metadata?.content || upstreamImage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mount = mountRef.current;
|
||||||
|
if (!mount || !source) return;
|
||||||
|
let disposed = false;
|
||||||
|
let cleanup = () => {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let lon = 0;
|
||||||
|
let lat = 0;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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" }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "panorama",
|
||||||
|
name: "3D 全景节点",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "查看 360° 等距柱状全景图,可从上游图片节点取图",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "panorama:viewer",
|
||||||
|
title: "3D 全景",
|
||||||
|
icon: "🌐",
|
||||||
|
description: "360° 全景查看器",
|
||||||
|
defaultSize: { width: 480, height: 300 },
|
||||||
|
defaultMetadata: {},
|
||||||
|
minimapColor: "#0ea5e9",
|
||||||
|
Content: PanoramaContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 官方插件注册表(集中构建 + 远程发布)
|
||||||
|
|
||||||
|
本目录**只放构建脚本**,不放构建产物。官方插件由 CI 构建后发布到孤儿分支 `plugins-dist`,画布经 jsDelivr 从该分支远程拉取并一键安装。第三方插件不进本流程,由用户自行填 JS URL 安装。
|
||||||
|
|
||||||
|
```
|
||||||
|
registry/
|
||||||
|
package.json # 构建依赖(esbuild + SDK)
|
||||||
|
build.mjs # 一次进程构建所有官方插件 → dist/(gitignore)+ 生成清单
|
||||||
|
dist/ # 构建产物(gitignore,不提交;CI 发布到 plugins-dist 分支)
|
||||||
|
```
|
||||||
|
|
||||||
|
**产物不进 git**:`dist/` 与 `node_modules/` 均被 `.gitignore` 覆盖。`main` 分支只有源码与脚本。
|
||||||
|
|
||||||
|
## 发布流程(CI 自动)
|
||||||
|
|
||||||
|
`.github/workflows/publish-plugins.yml` 在**打版本 tag(`v*`)**或手动触发(`workflow_dispatch`)时,与 GitHub Pages 发布一起跑:
|
||||||
|
|
||||||
|
1. `npm install && npm run build` → 在 `dist/` 产出各 `<id>.js` 与 `official-plugins.json`;
|
||||||
|
2. 把 `dist/` 强推到孤儿分支 **`plugins-dist`**(仅含产物,force-push 覆盖)。
|
||||||
|
|
||||||
|
前端默认从下面地址读取(可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json
|
||||||
|
```
|
||||||
|
|
||||||
|
清单里每条的 `entry`(相对文件名)由前端解析成与清单同目录的绝对 URL,再走既有 URL 安装流程。jsDelivr 对分支有缓存(约数小时),需要立即生效可对该分支目录做 purge。
|
||||||
|
|
||||||
|
## 新增 / 更新官方插件
|
||||||
|
|
||||||
|
- 改完某官方插件源码后,在 `build.mjs` 的 `OFFICIAL` 里保持登记(新增插件在此加一条),提交到 `main`;
|
||||||
|
- 下次打版本 tag(或手动 `workflow_dispatch`)时,CI 自动重新构建并发布到 `plugins-dist`。
|
||||||
|
|
||||||
|
## 本地自测官方面板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/canvas/registry && npm install && npm run build # 产出 dist/
|
||||||
|
# 用任意静态服务器伺服 dist/,把 VITE_PLUGIN_REGISTRY_URL 指向其 official-plugins.json
|
||||||
|
```
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 官方插件集中构建:一次进程构建所有官方插件 + 生成清单,产物进 dist/(已 gitignore)。
|
||||||
|
// 官方插件目录本身不各自安装依赖:统一从本目录 node_modules 解析 SDK(nodePaths)。
|
||||||
|
// 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 { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outDir = join(root, "dist");
|
||||||
|
const nodeModules = join(root, "node_modules");
|
||||||
|
|
||||||
|
// 官方插件元数据(单一真源:清单从这里生成)。新增官方插件在此登记即可。
|
||||||
|
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: "📌" },
|
||||||
|
];
|
||||||
|
|
||||||
|
await rm(outDir, { recursive: true, force: true });
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const plugin of OFFICIAL) {
|
||||||
|
await build({
|
||||||
|
entryPoints: [join(root, "..", plugin.dir, "src", "index.tsx")],
|
||||||
|
outfile: join(outDir, `${plugin.id}.js`),
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2020",
|
||||||
|
// automatic JSX → SDK 的 jsx-runtime(内部用宿主 React),react external
|
||||||
|
jsx: "automatic",
|
||||||
|
jsxImportSource: "@infinite-canvas/plugin-sdk",
|
||||||
|
loader: { ".ts": "ts", ".tsx": "tsx", ".css": "text" },
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
minify: true,
|
||||||
|
nodePaths: [nodeModules],
|
||||||
|
});
|
||||||
|
console.log(`built ${plugin.id}.js`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
|
||||||
|
console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`);
|
||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-official-plugins",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-official-plugins",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-official-plugins",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 官方插件的集中构建(CI 发布到 plugins-dist 分支)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# @infinite-canvas/plugin-sdk
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件的 **TypeScript SDK**。插件作者只写节点 UI 与逻辑,类型、JSX、运行时桥接、构建全部由 SDK 提供;产物仍是宿主加载器现有契约的 ESM(React external,宿主单例)。
|
||||||
|
|
||||||
|
## 提供什么
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| **完整类型** | `CanvasPlugin` / `CanvasNodeDefinition` / `CanvasNodeContext` / `CanvasAgentOp` / `CanvasTheme` / `CanvasNodeData` … 全部有提示 |
|
||||||
|
| `definePlugin(...)` | 给插件对象(或工厂)补全类型;对象形式无需再 `const { React } = runtime` |
|
||||||
|
| automatic JSX | `jsxImportSource` 指向本包,TSX 自动转发到宿主 React,**不打包第二份 React** |
|
||||||
|
| 类型化 hooks | `import { useState, useEffect, useMemo, useRef, ... }`,运行时转发宿主 React |
|
||||||
|
| `buildPlugin(...)` | 统一 esbuild 构建,插件 `build.mjs` 只需一行 |
|
||||||
|
|
||||||
|
## 最小插件
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/index.tsx
|
||||||
|
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
function Content({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [n, setN] = useState(0);
|
||||||
|
return (
|
||||||
|
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||||
|
{ctx.node.title}: {n}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "my-plugin",
|
||||||
|
name: "我的插件",
|
||||||
|
version: "1.0.0",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "my-plugin:node",
|
||||||
|
title: "示例",
|
||||||
|
icon: "✨",
|
||||||
|
defaultSize: { width: 240, height: 160 },
|
||||||
|
Content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// build.mjs
|
||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` 产出 `dist/<目录名>.js` 并同步到 `web/public/plugins/`。
|
||||||
|
|
||||||
|
## 依赖接入
|
||||||
|
|
||||||
|
插件 `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"scripts": { "build": "node build.mjs", "dev": "node build.mjs --watch", "typecheck": "tsc --noEmit" },
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
插件 `tsconfig.json` 关键项:`"jsx": "react-jsx"`、`"jsxImportSource": "@infinite-canvas/plugin-sdk"`、`"moduleResolution": "bundler"`。
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- **React 单例**:JSX 与 hooks 惰性读取 `globalThis.InfiniteCanvasRuntime.React`(宿主在加载插件前注入),react 全程 external,绝不打包第二份。
|
||||||
|
- **重依赖**:three、marked 等在源码里 `await import("https://esm.sh/...")` 动态加载,esbuild 自动 external,不进 bundle。
|
||||||
|
- **类型真源**:`src/types.ts` 是宿主 `web/src/types/canvas-plugin.ts` 公开契约的镜像;宿主契约变更时同步此处。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 共享构建助手:插件的 build.mjs 只需一行 `buildPlugin(import.meta.url)`。
|
||||||
|
// 统一 esbuild 配置(automatic JSX 指向本 SDK、react external、TS/TSX/CSS loader、
|
||||||
|
// 产物同步到 web/public/plugins),消除各插件重复的构建脚本。
|
||||||
|
|
||||||
|
import { build, context } from "esbuild";
|
||||||
|
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} metaUrl 插件 build.mjs 的 import.meta.url
|
||||||
|
* @param {{ name?: string, entry?: string, publicDir?: string, esbuild?: object, plugins?: import("esbuild").Plugin[] }} [overrides]
|
||||||
|
*/
|
||||||
|
export async function buildPlugin(metaUrl, overrides = {}) {
|
||||||
|
const root = dirname(fileURLToPath(metaUrl));
|
||||||
|
const name = overrides.name ?? basename(root); // 目录名即产物名,如 markdown → markdown.js
|
||||||
|
const distDir = join(root, "dist");
|
||||||
|
// 默认同步到仓库内 web/public/plugins(plugins/canvas/<name> → 上溯三层到仓库根)
|
||||||
|
const publicDir = overrides.publicDir ?? join(root, "..", "..", "..", "web", "public", "plugins");
|
||||||
|
const watch = process.argv.includes("--watch");
|
||||||
|
const entry = overrides.entry ?? join(root, "src", "index.tsx");
|
||||||
|
|
||||||
|
const syncToPublic = {
|
||||||
|
name: "sync-to-public",
|
||||||
|
setup(builder) {
|
||||||
|
builder.onEnd(async (result) => {
|
||||||
|
if (result.errors.length) return;
|
||||||
|
await mkdir(publicDir, { recursive: true });
|
||||||
|
await cp(join(distDir, `${name}.js`), join(publicDir, `${name}.js`));
|
||||||
|
// 维护本地插件清单 index.json,供画布启动时自动发现(该目录已 gitignore,仅本地开发用)
|
||||||
|
const indexPath = join(publicDir, "index.json");
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await readFile(indexPath, "utf8"));
|
||||||
|
if (Array.isArray(parsed)) list = parsed;
|
||||||
|
} catch {
|
||||||
|
// 无清单则新建
|
||||||
|
}
|
||||||
|
const entry = `/plugins/${name}.js`;
|
||||||
|
if (!list.includes(entry)) {
|
||||||
|
list.push(entry);
|
||||||
|
await writeFile(indexPath, JSON.stringify(list, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
console.log(`[${name}] synced → web/public/plugins/${name}.js`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
entryPoints: [entry],
|
||||||
|
outfile: join(distDir, `${name}.js`),
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2020",
|
||||||
|
// automatic JSX → 转发到本 SDK 的 jsx-runtime(内部用宿主 React),插件无需自带 React
|
||||||
|
jsx: "automatic",
|
||||||
|
jsxImportSource: "@infinite-canvas/plugin-sdk",
|
||||||
|
loader: { ".js": "jsx", ".jsx": "jsx", ".ts": "ts", ".tsx": "tsx", ".css": "text" },
|
||||||
|
// 宿主提供单例 React,插件不打包 react;https:// 依赖 esbuild 自动 external
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
minify: !watch,
|
||||||
|
plugins: [syncToPublic, ...(overrides.plugins ?? [])],
|
||||||
|
...overrides.esbuild,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
const ctx = await context(options);
|
||||||
|
await ctx.watch();
|
||||||
|
console.log(`[${name}] watching src/ ...`);
|
||||||
|
} else {
|
||||||
|
await build(options);
|
||||||
|
console.log(`[${name}] built → dist/${name}.js`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+535
@@ -0,0 +1,535 @@
|
|||||||
|
{
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 画布节点插件 SDK:类型、automatic JSX 运行时、构建助手",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./jsx-runtime": "./src/jsx-runtime.ts",
|
||||||
|
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
|
||||||
|
"./build": "./build.mjs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { CanvasPlugin, CanvasPluginFactory, PluginRuntime } from "./types";
|
||||||
|
|
||||||
|
// 身份帮助函数:为插件对象(或工厂)补全类型,给作者完整补全与校验。
|
||||||
|
// 采用 automatic JSX 后,插件通常不再需要 runtime,直接写对象形式即可:
|
||||||
|
//
|
||||||
|
// export default definePlugin({ id, name, version, nodes: [...] })
|
||||||
|
//
|
||||||
|
// 仍支持工厂形式(需要 runtime.version/emit/on 时):
|
||||||
|
//
|
||||||
|
// export default definePlugin((runtime) => ({ ... }))
|
||||||
|
|
||||||
|
export function definePlugin(plugin: CanvasPlugin): CanvasPlugin;
|
||||||
|
export function definePlugin(factory: CanvasPluginFactory): CanvasPluginFactory;
|
||||||
|
export function definePlugin(input: CanvasPlugin | CanvasPluginFactory): CanvasPlugin | CanvasPluginFactory {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PluginRuntime };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// @infinite-canvas/plugin-sdk 公开入口。
|
||||||
|
//
|
||||||
|
// 插件作者用 TS/TSX 编写,只关注节点 UI 与逻辑;类型、JSX、运行时桥接、构建
|
||||||
|
// 全部由本 SDK 提供,产物仍是宿主 loader 现有契约的 ESM(React external,宿主单例)。
|
||||||
|
|
||||||
|
export * from "./types";
|
||||||
|
export { definePlugin } from "./define-plugin";
|
||||||
|
export { getReact, getRuntime, useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useReducer, useContext, useId } from "./runtime";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// automatic JSX 的 dev 变体(编译器在 dev 模式会引用 jsxDEV)。转发到同一套 createElement。
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { getReact } from "./runtime";
|
||||||
|
import { Fragment } from "./jsx-runtime";
|
||||||
|
|
||||||
|
export { Fragment };
|
||||||
|
export type { JSX } from "./jsx-runtime";
|
||||||
|
|
||||||
|
export function jsxDEV(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||||
|
const react = getReact();
|
||||||
|
const resolvedType = type === Fragment ? react.Fragment : type;
|
||||||
|
const config = key === undefined ? props : { ...(props ?? {}), key };
|
||||||
|
return react.createElement(resolvedType as never, config as never);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// automatic JSX 运行时:esbuild/tsc 的 `jsxImportSource` 指向本包时,
|
||||||
|
// TSX 会被编译成对本模块 jsx()/jsxs() 的调用。这里统一转发到宿主 React.createElement,
|
||||||
|
// 让插件写纯 TSX、无需手动 `const { React } = runtime`,同时 react 全程 external。
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { getReact } from "./runtime";
|
||||||
|
|
||||||
|
// Fragment 哨兵:渲染时才解析为宿主 React.Fragment,避免模块顶层触碰运行时。
|
||||||
|
export const Fragment = Symbol.for("infinite-canvas.jsx.fragment") as unknown as React.ExoticComponent<{ children?: React.ReactNode }>;
|
||||||
|
|
||||||
|
function createElement(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||||
|
const react = getReact();
|
||||||
|
const resolvedType = type === Fragment ? react.Fragment : type;
|
||||||
|
// automatic 运行时已把 children 放进 props;key 单独传入以避免展开 key 警告。
|
||||||
|
const config = key === undefined ? props : { ...(props ?? {}), key };
|
||||||
|
return react.createElement(resolvedType as never, config as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsx(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||||
|
return createElement(type, props, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsxs 用于静态多子节点;转发逻辑与 jsx 一致(children 已在 props 内)。
|
||||||
|
export const jsxs = jsx;
|
||||||
|
|
||||||
|
// 让 `jsxImportSource` 指向本包的编译器能从这里取到 JSX 内建标签类型(复用 @types/react)。
|
||||||
|
export namespace JSX {
|
||||||
|
export type Element = React.JSX.Element;
|
||||||
|
export type ElementType = React.JSX.ElementType;
|
||||||
|
export type ElementClass = React.JSX.ElementClass;
|
||||||
|
export type ElementAttributesProperty = React.JSX.ElementAttributesProperty;
|
||||||
|
export type ElementChildrenAttribute = React.JSX.ElementChildrenAttribute;
|
||||||
|
export type LibraryManagedAttributes<C, P> = React.JSX.LibraryManagedAttributes<C, P>;
|
||||||
|
export type IntrinsicAttributes = React.JSX.IntrinsicAttributes;
|
||||||
|
export type IntrinsicClassAttributes<T> = React.JSX.IntrinsicClassAttributes<T>;
|
||||||
|
export type IntrinsicElements = React.JSX.IntrinsicElements;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 运行时桥接:所有对宿主 React 的访问都惰性读取全局运行时,
|
||||||
|
// 保证插件与画布共用同一份 React(不打包第二份),且不在模块顶层触碰运行时
|
||||||
|
// (宿主 loader 会先 import 插件模块、再设置运行时)。
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "./types";
|
||||||
|
|
||||||
|
type RuntimeGlobal = { InfiniteCanvasRuntime?: PluginRuntime };
|
||||||
|
|
||||||
|
/** 取宿主注入的插件运行时(含 React、事件总线、injectCSS 等)。 */
|
||||||
|
export function getRuntime(): PluginRuntime {
|
||||||
|
const runtime = (globalThis as unknown as RuntimeGlobal).InfiniteCanvasRuntime;
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("[plugin-sdk] Infinite Canvas 运行时未就绪:请在画布宿主中加载本插件");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取宿主的 React 实例。仅在渲染/hook 调用时(运行时已就绪)使用。 */
|
||||||
|
export function getReact(): typeof React {
|
||||||
|
return getRuntime().React as unknown as typeof React;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 类型完整的 hooks 转发:签名取自 @types/react,运行时转发到宿主 React ---
|
||||||
|
// 这样插件作者可以直接 `import { useState } from "@infinite-canvas/plugin-sdk"`,
|
||||||
|
// 无需再从 runtime 里解构 React。
|
||||||
|
|
||||||
|
export const useState: typeof React.useState = ((...args: unknown[]) => (getReact().useState as (...a: unknown[]) => unknown)(...args)) as typeof React.useState;
|
||||||
|
export const useEffect: typeof React.useEffect = ((...args: unknown[]) => (getReact().useEffect as (...a: unknown[]) => unknown)(...args)) as typeof React.useEffect;
|
||||||
|
export const useLayoutEffect: typeof React.useLayoutEffect = ((...args: unknown[]) => (getReact().useLayoutEffect as (...a: unknown[]) => unknown)(...args)) as typeof React.useLayoutEffect;
|
||||||
|
export const useMemo: typeof React.useMemo = ((...args: unknown[]) => (getReact().useMemo as (...a: unknown[]) => unknown)(...args)) as typeof React.useMemo;
|
||||||
|
export const useCallback: typeof React.useCallback = ((...args: unknown[]) => (getReact().useCallback as (...a: unknown[]) => unknown)(...args)) as typeof React.useCallback;
|
||||||
|
export const useRef: typeof React.useRef = ((...args: unknown[]) => (getReact().useRef as (...a: unknown[]) => unknown)(...args)) as typeof React.useRef;
|
||||||
|
export const useReducer: typeof React.useReducer = ((...args: unknown[]) => (getReact().useReducer as (...a: unknown[]) => unknown)(...args)) as typeof React.useReducer;
|
||||||
|
export const useContext: typeof React.useContext = ((...args: unknown[]) => (getReact().useContext as (...a: unknown[]) => unknown)(...args)) as typeof React.useContext;
|
||||||
|
export const useId: typeof React.useId = ((...args: unknown[]) => (getReact().useId as (...a: unknown[]) => unknown)(...args)) as typeof React.useId;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Infinite Canvas 插件公共契约类型。
|
||||||
|
//
|
||||||
|
// 这是插件作者面向的「公开接口」子集,自包含、不依赖宿主 `@/` 内部模块,
|
||||||
|
// 因此可以被独立构建的插件包直接 import,获得完整的 TS 提示。
|
||||||
|
//
|
||||||
|
// 真源:宿主 `web/src/types/canvas-plugin.ts` 及其引用的类型。本文件是它的公开镜像,
|
||||||
|
// 若宿主契约变更,请同步更新此处(两者结构保持一致即可,无需逐字节相同)。
|
||||||
|
|
||||||
|
import type { ComponentType, ReactNode } from "react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 画布基础几何与节点数据
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Position = { x: number; y: number };
|
||||||
|
|
||||||
|
export type ViewportTransform = { x: number; y: number; k: number };
|
||||||
|
|
||||||
|
// 内置节点类型;插件节点建议用 "<pluginId>:<name>"。放开为字符串以便扩展。
|
||||||
|
export type CanvasBuiltinNodeType = "image" | "text" | "config" | "video" | "audio" | "group";
|
||||||
|
export type CanvasNodeTypeId = CanvasBuiltinNodeType | (string & {});
|
||||||
|
|
||||||
|
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||||
|
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||||
|
export type CanvasImageGenerationType = "generation" | "edit";
|
||||||
|
|
||||||
|
// 节点 metadata 是扁平可选字段袋;插件自定义字段可直接写入(内容惯例放 content)。
|
||||||
|
export type CanvasNodeMetadata = {
|
||||||
|
content?: string;
|
||||||
|
composerContent?: string;
|
||||||
|
prompt?: string;
|
||||||
|
status?: CanvasNodeStatus;
|
||||||
|
errorDetails?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
generationMode?: CanvasGenerationMode;
|
||||||
|
generationType?: CanvasImageGenerationType;
|
||||||
|
model?: string;
|
||||||
|
size?: string;
|
||||||
|
quality?: string;
|
||||||
|
count?: number;
|
||||||
|
seconds?: string;
|
||||||
|
vquality?: string;
|
||||||
|
generateAudio?: string;
|
||||||
|
watermark?: string;
|
||||||
|
audioVoice?: string;
|
||||||
|
audioFormat?: string;
|
||||||
|
audioSpeed?: string;
|
||||||
|
audioInstructions?: string;
|
||||||
|
references?: string[];
|
||||||
|
naturalWidth?: number;
|
||||||
|
naturalHeight?: number;
|
||||||
|
freeResize?: boolean;
|
||||||
|
isBatchRoot?: boolean;
|
||||||
|
batchRootId?: string;
|
||||||
|
batchChildIds?: string[];
|
||||||
|
batchUsesReferenceImages?: boolean;
|
||||||
|
primaryImageId?: string;
|
||||||
|
imageBatchExpanded?: boolean;
|
||||||
|
storageKey?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
bytes?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
groupId?: string;
|
||||||
|
// 插件可写入任意自定义字段
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeData = {
|
||||||
|
id: string;
|
||||||
|
type: CanvasNodeTypeId;
|
||||||
|
title: string;
|
||||||
|
position: Position;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
metadata?: CanvasNodeMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasConnection = {
|
||||||
|
id: string;
|
||||||
|
fromNodeId: string;
|
||||||
|
toNodeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 主题 token(用来让插件 UI 跟随画布明暗主题)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasTheme = {
|
||||||
|
canvas: {
|
||||||
|
background: string;
|
||||||
|
dot: string;
|
||||||
|
line: string;
|
||||||
|
selectionStroke: string;
|
||||||
|
selectionFill: string;
|
||||||
|
};
|
||||||
|
node: {
|
||||||
|
label: string;
|
||||||
|
fill: string;
|
||||||
|
panel: string;
|
||||||
|
stroke: string;
|
||||||
|
activeStroke: string;
|
||||||
|
placeholder: string;
|
||||||
|
text: string;
|
||||||
|
muted: string;
|
||||||
|
faint: string;
|
||||||
|
};
|
||||||
|
toolbar: {
|
||||||
|
panel: string;
|
||||||
|
border: string;
|
||||||
|
item: string;
|
||||||
|
itemHover: string;
|
||||||
|
activeBg: string;
|
||||||
|
activeText: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 画布指令集(ctx.applyOps):与 AI Agent 同级的画布操作能力
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasAgentOp =
|
||||||
|
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||||
|
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||||
|
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||||
|
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||||
|
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||||
|
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||||
|
| { type: "select_nodes"; ids: string[] }
|
||||||
|
| { type: "run_generation"; nodeId: string; mode?: CanvasGenerationMode; prompt?: string };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 资源:插件节点作为上游输入被消费时输出什么(接入生成/引用体系)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||||
|
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PluginStorage = {
|
||||||
|
get: <T = unknown>(key: string) => Promise<T | null>;
|
||||||
|
set: (key: string, value: unknown) => Promise<void>;
|
||||||
|
remove: (key: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeContext = {
|
||||||
|
// 自身数据
|
||||||
|
node: CanvasNodeData;
|
||||||
|
theme: CanvasTheme;
|
||||||
|
scale: number;
|
||||||
|
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||||
|
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||||
|
// 图访问
|
||||||
|
getNode: (id: string) => CanvasNodeData | null;
|
||||||
|
getNodes: () => CanvasNodeData[];
|
||||||
|
getConnections: () => CanvasConnection[];
|
||||||
|
getUpstream: () => CanvasNodeData[];
|
||||||
|
getDownstream: () => CanvasNodeData[];
|
||||||
|
// 画布操作(复用 Agent 指令集)
|
||||||
|
applyOps: (ops: CanvasAgentOp[]) => void;
|
||||||
|
// 节点间/插件间通信
|
||||||
|
emit: (event: string, payload?: unknown) => void;
|
||||||
|
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||||
|
// 插件私有持久化,按插件 id 命名空间隔离
|
||||||
|
storage: PluginStorage;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 节点定义:内置节点与插件节点统一走这套结构
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasNodeToolbarItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeContentProps = { ctx: CanvasNodeContext };
|
||||||
|
export type CanvasNodePanelProps = { ctx: CanvasNodeContext; onClose: () => void };
|
||||||
|
|
||||||
|
export type CanvasNodeDefinition = {
|
||||||
|
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||||
|
description?: string;
|
||||||
|
defaultSize: { width: number; height: number };
|
||||||
|
defaultMetadata?: CanvasNodeMetadata;
|
||||||
|
minimapColor?: string;
|
||||||
|
showInCreateMenu?: boolean; // 默认 true
|
||||||
|
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||||
|
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||||
|
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||||
|
// 渲染
|
||||||
|
Content?: ComponentType<CanvasNodeContentProps>;
|
||||||
|
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板
|
||||||
|
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
|
||||||
|
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 插件运行时与插件包
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 插件启动时(setup)可访问的应用能力
|
||||||
|
export type CanvasPluginApp = {
|
||||||
|
version: string;
|
||||||
|
emit: (event: string, payload?: unknown) => void;
|
||||||
|
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||||
|
// 注入插件样式,返回移除函数;传 key 时同 key 覆盖旧样式
|
||||||
|
injectCSS: (css: string, key?: string) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 宿主注入的运行时(工厂形式插件的入参),内含宿主 React 实例避免双 React
|
||||||
|
export type PluginRuntime = CanvasPluginApp & {
|
||||||
|
React: typeof import("react");
|
||||||
|
jsx: typeof import("react").createElement;
|
||||||
|
Fragment: typeof import("react").Fragment;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插件包(默认导出对象,或返回它的工厂函数)
|
||||||
|
export type CanvasPlugin = {
|
||||||
|
id: string; // 唯一,kebab-case
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
minAppVersion?: string;
|
||||||
|
css?: string; // 插件样式,启用时自动注入、卸载/禁用时自动清理
|
||||||
|
nodes: CanvasNodeDefinition[];
|
||||||
|
setup?: (app: CanvasPluginApp) => void | (() => void);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasPluginFactory = (runtime: PluginRuntime) => CanvasPlugin;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": ".",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false
|
||||||
|
},
|
||||||
|
"include": ["src", "build.mjs"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 便利贴节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:彩色便利贴,可换色、编辑,并演示用 `ctx.applyOps` 衍生一个连到自己的文本节点(节点间交互示例)。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/sticky-note.js,并同步到 web/public/plugins/sticky-note.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/sticky-note.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-sticky-note",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-sticky-note",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-sticky-note",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 便利贴节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 便利贴节点:可换色、可编辑、可衍生文本节点(演示 ctx.applyOps 画布指令集)。
|
||||||
|
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
const COLORS = ["#fde68a", "#fca5a5", "#a7f3d0", "#bfdbfe", "#ddd6fe"];
|
||||||
|
|
||||||
|
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 cycleColor = () => {
|
||||||
|
const next = COLORS[(COLORS.indexOf(color) + 1) % COLORS.length];
|
||||||
|
ctx.updateMetadata({ pluginColor: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
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 btn = { width: 26, height: 26, borderRadius: 8, border: "none", cursor: "pointer", background: "rgba(0,0,0,.08)", fontSize: 13 } as const;
|
||||||
|
|
||||||
|
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>
|
||||||
|
{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 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, whiteSpace: "pre-wrap", overflow: "auto", color: "#1c1917", fontSize: 15, lineHeight: 1.5 }}>{content || "双击 ✎ 编辑便利贴"}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "sticky-note",
|
||||||
|
name: "便利贴节点",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "可换色、可编辑、可衍生文本节点的便利贴",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "sticky-note:note",
|
||||||
|
title: "便利贴",
|
||||||
|
icon: "📌",
|
||||||
|
description: "彩色便利贴",
|
||||||
|
defaultSize: { width: 240, height: 200 },
|
||||||
|
defaultMetadata: { content: "", pluginColor: "#fde68a" },
|
||||||
|
minimapColor: "#f59e0b",
|
||||||
|
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||||
|
Content: StickyNoteContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# SVG 节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:编辑与渲染 SVG,无自身内容时自动取上游文本节点里的 SVG 源码。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/svg.js,并同步到 web/public/plugins/svg.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/svg.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
Generated
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-svg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-svg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-svg",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas SVG 节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// SVG 节点:编辑与渲染 SVG,无自身内容时可取上游文本节点里的 SVG 源码。
|
||||||
|
import { definePlugin, useEffect, 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 upstream = ctx
|
||||||
|
.getUpstream()
|
||||||
|
.map((node) => node.metadata?.content)
|
||||||
|
.find((text) => text?.trim().startsWith("<svg"));
|
||||||
|
const value = stored ?? "";
|
||||||
|
const svg = value.trim() || upstream || DEFAULT_SVG;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stored === undefined && upstream) ctx.updateMetadata({ content: upstream });
|
||||||
|
}, [ctx, stored, upstream]);
|
||||||
|
|
||||||
|
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 ? "预览" : "编辑源码"}>
|
||||||
|
{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 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "svg",
|
||||||
|
name: "SVG 节点",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "svg:vector",
|
||||||
|
title: "SVG",
|
||||||
|
icon: "🔷",
|
||||||
|
description: "渲染 SVG 矢量图",
|
||||||
|
defaultSize: { width: 320, height: 320 },
|
||||||
|
defaultMetadata: {},
|
||||||
|
minimapColor: "#14b8a6",
|
||||||
|
Content: SvgContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 插件模板(TypeScript + SDK)
|
||||||
|
|
||||||
|
复制本目录即可开始写一个新的画布节点插件。
|
||||||
|
|
||||||
|
## 上手
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r plugins/canvas/template plugins/canvas/my-plugin
|
||||||
|
cd plugins/canvas/my-plugin
|
||||||
|
# 改 package.json 的 name;改 src/index.tsx 里的 id / name / type
|
||||||
|
npm install
|
||||||
|
npm run dev # watch 构建,产物同步到 web/public/plugins/my-plugin.js
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
然后启动画布 `web`,在「节点插件」管理器里启用(自动发现)即可看到你的节点。发布时把 `dist/<name>.js` 托管到任意静态地址,用户填 URL 安装。
|
||||||
|
|
||||||
|
> 产物名取**目录名**(`my-plugin/` → `my-plugin.js`),所以复制后记得把目录改成你的插件名。
|
||||||
|
|
||||||
|
## 你只需要关心
|
||||||
|
|
||||||
|
- `src/index.tsx` 里 `Content({ ctx })` 的节点 UI 与逻辑;
|
||||||
|
- `definePlugin({...})` 里的 `id` / `nodes[].type` / `defaultSize` 等元信息。
|
||||||
|
|
||||||
|
类型、JSX、宿主 React、构建都由 `@infinite-canvas/plugin-sdk` 提供,写 TSX 全程有补全。`ctx` 的完整能力见 `plugins/canvas/README.md` 与 SDK 的类型定义。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 插件起步模板(TypeScript + SDK)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// 插件模板:复制本目录 → 改 id/name/type → 写你的节点。
|
||||||
|
// 这是一个演示节点:编辑文本、跟随主题、读取上游节点、用画布指令衍生新节点。
|
||||||
|
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
function TemplateContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const content = ctx.node.metadata?.content || "";
|
||||||
|
|
||||||
|
// 读取上游相连节点的文本内容(演示 ctx.getUpstream)
|
||||||
|
const upstreamText = ctx
|
||||||
|
.getUpstream()
|
||||||
|
.map((node) => node.metadata?.content)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// 用画布指令集衍生一个文本节点并连线(演示 ctx.applyOps)
|
||||||
|
const spawnBelow = () => {
|
||||||
|
const id = `template-${ctx.node.id}-${ctx.getNodes().length}`;
|
||||||
|
ctx.applyOps([
|
||||||
|
{ type: "add_node", id, nodeType: "text", title: "衍生节点", x: ctx.node.position.x, y: ctx.node.position.y + ctx.node.height + 40, metadata: { content, status: "success" } },
|
||||||
|
{ type: "connect_nodes", fromNodeId: ctx.node.id, toNodeId: id },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = { padding: "4px 10px", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: ctx.theme.toolbar.panel, color: ctx.theme.node.text, cursor: "pointer", fontSize: 12 } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// data-canvas-no-zoom + stopPropagation:交互控件避免触发画布拖拽/缩放
|
||||||
|
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", gap: 8, padding: 12, boxSizing: "border-box", color: ctx.theme.node.text }}>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button type="button" style={btn} onClick={() => setEditing((v) => !v)}>
|
||||||
|
{editing ? "完成" : "编辑"}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={btn} onClick={spawnBelow}>
|
||||||
|
衍生文本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<textarea autoFocus value={content} placeholder="输入内容…" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ flex: 1, resize: "none", border: "none", outline: "none", background: "transparent", color: ctx.theme.node.text, fontSize: 14, lineHeight: 1.5 }} />
|
||||||
|
) : (
|
||||||
|
<div onWheel={(e) => e.stopPropagation()} style={{ flex: 1, overflow: "auto", whiteSpace: "pre-wrap", fontSize: 14, lineHeight: 1.5 }}>
|
||||||
|
{content || upstreamText || <span style={{ color: ctx.theme.node.placeholder }}>点“编辑”写点什么,或连一个上游文本节点</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "template", // ← 改成你的唯一 id(kebab-case)
|
||||||
|
name: "模板节点",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "插件起步模板:改我。",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "template:node", // ← 建议 "<id>:<name>",全局唯一
|
||||||
|
title: "模板",
|
||||||
|
icon: "✨",
|
||||||
|
description: "起步示例节点",
|
||||||
|
defaultSize: { width: 280, height: 200 },
|
||||||
|
defaultMetadata: { content: "" },
|
||||||
|
minimapColor: "#8b5cf6",
|
||||||
|
// 作为上游输入被消费时输出文本(可连给生成/其它节点);不需要可删
|
||||||
|
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||||
|
Content: TemplateContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
+24
-3
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.9",
|
||||||
"@uiw/react-codemirror": "^4.25.9",
|
"@uiw/react-codemirror": "^4.25.9",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"streamdown": "^2.5.0",
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"three": "^0.180.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.12",
|
"zustand": "^5.0.12",
|
||||||
},
|
},
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "19.1.12",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "19.1.9",
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
@@ -138,6 +141,8 @@
|
|||||||
|
|
||||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||||
|
|
||||||
|
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||||
|
|
||||||
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
|
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
|
||||||
|
|
||||||
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
||||||
@@ -154,6 +159,8 @@
|
|||||||
|
|
||||||
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="],
|
"@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="],
|
||||||
|
|
||||||
|
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
||||||
|
|
||||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
|
|
||||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||||
@@ -278,6 +285,8 @@
|
|||||||
|
|
||||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||||
|
|
||||||
|
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
|
||||||
|
|
||||||
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
|
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
|
||||||
|
|
||||||
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||||
@@ -604,6 +613,8 @@
|
|||||||
|
|
||||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||||
|
|
||||||
|
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||||
|
|
||||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
@@ -702,14 +713,20 @@
|
|||||||
|
|
||||||
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
|
"@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "https://registry.npmmirror.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
|
||||||
|
|
||||||
|
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
||||||
|
|
||||||
"@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
|
"@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
|
||||||
|
|
||||||
|
"@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
"@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||||
|
|
||||||
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||||
|
|
||||||
|
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
||||||
|
|
||||||
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="],
|
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="],
|
||||||
|
|
||||||
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="],
|
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="],
|
||||||
@@ -724,6 +741,8 @@
|
|||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||||
|
|
||||||
|
"@webgpu/types": ["@webgpu/types@0.1.71", "", {}, "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A=="],
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
@@ -1144,7 +1163,7 @@
|
|||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.1.1", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="],
|
"inline-style-parser": ["inline-style-parser@0.1.1", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="],
|
||||||
|
|
||||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
"internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||||
|
|
||||||
"ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
"ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||||
|
|
||||||
@@ -1324,6 +1343,8 @@
|
|||||||
|
|
||||||
"mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="],
|
"mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="],
|
||||||
|
|
||||||
|
"meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="],
|
||||||
|
|
||||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||||
|
|
||||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||||
@@ -1686,6 +1707,8 @@
|
|||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="],
|
||||||
|
|
||||||
"throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
"throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
@@ -2070,8 +2093,6 @@
|
|||||||
|
|
||||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||||
|
|
||||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
|
||||||
|
|
||||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||||
|
|
||||||
"express/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
"express/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.9",
|
||||||
"@uiw/react-codemirror": "^4.25.9",
|
"@uiw/react-codemirror": "^4.25.9",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
|
|
||||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||||
return (
|
return (
|
||||||
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||||
<MyAssetsTab onInsert={onInsert} />
|
<MyAssetsTab onInsert={onInsert} />
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -90,7 +90,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
|||||||
className="w-56"
|
className="w-56"
|
||||||
size="small"
|
size="small"
|
||||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||||
placeholder="搜索素材"
|
placeholder="搜索资产"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
allowClear
|
allowClear
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -122,7 +122,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length > PAGE_SIZE && (
|
{filtered.length > PAGE_SIZE && (
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
|
|||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-baseline gap-2">
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
||||||
<div className="truncate text-[11px] opacity-55">@ 引用已连接素材,发送前按当前连接重新编号</div>
|
<div className="truncate text-[11px] opacity-55">@ 引用已连接资产,发送前按当前连接重新编号</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
|||||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||||
const currentModel = node.metadata?.model;
|
const currentModel = node.metadata?.model;
|
||||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||||
? currentModel
|
? currentModel
|
||||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||||
? defaultModel
|
? defaultModel
|
||||||
: fallbackModel;
|
: fallbackModel;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -927,8 +927,8 @@ function siteToolSummary(name: string, result: unknown) {
|
|||||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个素材`;
|
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||||
if (name === "assets_add") return "已加入我的素材";
|
if (name === "assets_add") return "已加入我的资产";
|
||||||
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
||||||
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||||
return "已完成";
|
return "已完成";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
import { type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||||
|
|
||||||
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
|
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
@@ -113,7 +114,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
|
|||||||
>
|
>
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const pos = toMinimap(node.position.x, node.position.y);
|
const pos = toMinimap(node.position.x, node.position.y);
|
||||||
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted;
|
const color = getNodeDefinition(node.type)?.minimapColor || theme.node.muted;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
|||||||
import { useCopyText } from "@/hooks/use-copy-text";
|
import { useCopyText } from "@/hooks/use-copy-text";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||||
|
import type { CanvasNodeToolbarItem } from "@/types/canvas-plugin";
|
||||||
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
|
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
|
||||||
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ type CanvasNodeHoverToolbarProps = {
|
|||||||
onRetry: (node: CanvasNodeData) => void;
|
onRetry: (node: CanvasNodeData) => void;
|
||||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||||
onDelete: (node: CanvasNodeData) => void;
|
onDelete: (node: CanvasNodeData) => void;
|
||||||
|
extraTools?: CanvasNodeToolbarItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolbarTool = {
|
type ToolbarTool = {
|
||||||
@@ -72,6 +74,7 @@ export function CanvasNodeHoverToolbar({
|
|||||||
onRetry,
|
onRetry,
|
||||||
onToggleFreeResize,
|
onToggleFreeResize,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
extraTools = [],
|
||||||
}: CanvasNodeHoverToolbarProps) {
|
}: CanvasNodeHoverToolbarProps) {
|
||||||
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||||
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||||
@@ -137,7 +140,7 @@ export function CanvasNodeHoverToolbar({
|
|||||||
];
|
];
|
||||||
const nodeToolbarTools: ToolbarTool[] = [
|
const nodeToolbarTools: ToolbarTool[] = [
|
||||||
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
||||||
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||||
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||||
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||||
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||||
@@ -150,7 +153,7 @@ export function CanvasNodeHoverToolbar({
|
|||||||
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
|
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||||
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
|
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
|
||||||
];
|
];
|
||||||
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
|
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
|
||||||
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
|
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
|
||||||
|
|
||||||
const closeImageToolSettings = () => {
|
const closeImageToolSettings = () => {
|
||||||
|
|||||||
@@ -134,9 +134,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
|||||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||||
const currentModel = node.metadata?.model;
|
const currentModel = node.metadata?.model;
|
||||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||||
? currentModel
|
? currentModel
|
||||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||||
? defaultModel
|
? defaultModel
|
||||||
: fallbackModel;
|
: fallbackModel;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes } from "@/lib/image-utils";
|
import { formatBytes } from "@/lib/image-utils";
|
||||||
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||||
|
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||||
|
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||||
|
|
||||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
@@ -25,6 +28,8 @@ type CanvasNodeProps = {
|
|||||||
showImageInfo: boolean;
|
showImageInfo: boolean;
|
||||||
resourceLabel?: CanvasResourceReference;
|
resourceLabel?: CanvasResourceReference;
|
||||||
mentionReferences?: CanvasResourceReference[];
|
mentionReferences?: CanvasResourceReference[];
|
||||||
|
pluginHost?: CanvasPluginHost;
|
||||||
|
registryVersion?: number;
|
||||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||||
batchCount?: number;
|
batchCount?: number;
|
||||||
@@ -61,6 +66,7 @@ type NodeContentRendererProps = {
|
|||||||
batchOpening: boolean;
|
batchOpening: boolean;
|
||||||
batchRecovering: boolean;
|
batchRecovering: boolean;
|
||||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||||
|
pluginContext?: CanvasNodeContext | null;
|
||||||
onContentChange: (nodeId: string, content: string) => void;
|
onContentChange: (nodeId: string, content: string) => void;
|
||||||
onStopEditing: () => void;
|
onStopEditing: () => void;
|
||||||
mentionReferences: CanvasResourceReference[];
|
mentionReferences: CanvasResourceReference[];
|
||||||
@@ -84,6 +90,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
showImageInfo,
|
showImageInfo,
|
||||||
resourceLabel,
|
resourceLabel,
|
||||||
mentionReferences = [],
|
mentionReferences = [],
|
||||||
|
pluginHost,
|
||||||
renderPanel,
|
renderPanel,
|
||||||
renderNodeContent,
|
renderNodeContent,
|
||||||
batchCount = 0,
|
batchCount = 0,
|
||||||
@@ -110,6 +117,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
}: CanvasNodeProps) {
|
}: CanvasNodeProps) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const definition = getNodeDefinition(data.type);
|
||||||
|
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
|
||||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
||||||
@@ -259,7 +268,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
startTop: data.position.y,
|
startTop: data.position.y,
|
||||||
startWidth: data.width,
|
startWidth: data.width,
|
||||||
startHeight: data.height,
|
startHeight: data.height,
|
||||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
|
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
|
||||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||||
};
|
};
|
||||||
window.addEventListener("mousemove", handleResizeMove);
|
window.addEventListener("mousemove", handleResizeMove);
|
||||||
@@ -343,6 +352,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
onToggleBatch?.(data.id);
|
onToggleBatch?.(data.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (definition?.onDoubleClick && pluginContext) {
|
||||||
|
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onViewImage?.(data);
|
onViewImage?.(data);
|
||||||
@@ -377,6 +390,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
batchOpening={batchOpening}
|
batchOpening={batchOpening}
|
||||||
batchRecovering={batchRecovering}
|
batchRecovering={batchRecovering}
|
||||||
renderNodeContent={renderNodeContent}
|
renderNodeContent={renderNodeContent}
|
||||||
|
pluginContext={pluginContext}
|
||||||
mentionReferences={mentionReferences}
|
mentionReferences={mentionReferences}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
onStopEditing={() => setIsEditingContent(false)}
|
onStopEditing={() => setIsEditingContent(false)}
|
||||||
@@ -400,7 +414,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||||
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||||
|
|
||||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -413,8 +427,16 @@ function NodeContent(props: NodeContentRendererProps) {
|
|||||||
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
|
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
|
||||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||||
|
|
||||||
const Renderer = nodeContentRenderers[props.node.type];
|
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
|
||||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
if (Renderer) return <Renderer {...props} />;
|
||||||
|
|
||||||
|
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
|
||||||
|
const definition = getNodeDefinition(props.node.type);
|
||||||
|
if (definition?.Content && props.pluginContext) {
|
||||||
|
const PluginContent = definition.Content;
|
||||||
|
return <PluginContent ctx={props.pluginContext} />;
|
||||||
|
}
|
||||||
|
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeContentRenderers = {
|
const nodeContentRenderers = {
|
||||||
@@ -473,10 +495,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
|
||||||
未知节点
|
<Puzzle className="size-7 opacity-40" />
|
||||||
|
<span className="text-sm">缺少插件</span>
|
||||||
|
<span className="text-[11px] opacity-70">节点类型 “{type}” 的插件未安装或未启用</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
|
||||||
|
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 { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||||
|
|
||||||
|
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const plugins = usePluginStore((state) => state.plugins);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [official, setOfficial] = useState<OfficialPluginEntry[]>([]);
|
||||||
|
const [loadingOfficial, setLoadingOfficial] = useState(false);
|
||||||
|
const [officialError, setOfficialError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const recordById = useMemo(() => new Map(plugins.map((item) => [item.id, item])), [plugins]);
|
||||||
|
const localPlugins = useMemo(() => plugins.filter((item) => item.local), [plugins]);
|
||||||
|
const thirdPartyPlugins = useMemo(() => plugins.filter((item) => !item.local && !item.official), [plugins]);
|
||||||
|
|
||||||
|
const loadOfficial = useCallback(async () => {
|
||||||
|
setLoadingOfficial(true);
|
||||||
|
setOfficialError(null);
|
||||||
|
try {
|
||||||
|
setOfficial(await fetchOfficialPlugins());
|
||||||
|
} catch (error) {
|
||||||
|
setOfficialError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setLoadingOfficial(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 打开面板时拉取官方清单(仅在尚未加载过时,避免重复请求)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && official.length === 0 && !loadingOfficial && !officialError) void loadOfficial();
|
||||||
|
}, [open, official.length, loadingOfficial, officialError, loadOfficial]);
|
||||||
|
|
||||||
|
const handleInstallUrl = async () => {
|
||||||
|
const target = url.trim();
|
||||||
|
if (!target) return;
|
||||||
|
setInstalling(true);
|
||||||
|
try {
|
||||||
|
const plugin = await installPluginFromUrl(target);
|
||||||
|
message.success(`已安装插件 ${plugin.name}`);
|
||||||
|
setUrl("");
|
||||||
|
} catch (error) {
|
||||||
|
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallOfficial = async (entry: OfficialPluginEntry) => {
|
||||||
|
setBusyId(entry.id);
|
||||||
|
try {
|
||||||
|
const plugin = await installPluginFromUrl(entry.url, { official: true });
|
||||||
|
message.success(`已安装 ${plugin.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runOnPlugin = async (record: InstalledPlugin, action: () => Promise<void>, successText: string) => {
|
||||||
|
setBusyId(record.id);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
message.success(successText);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(`${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
|
||||||
|
const installedControls = (record: InstalledPlugin) => (
|
||||||
|
<>
|
||||||
|
<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)), "已更新")} />
|
||||||
|
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
|
||||||
|
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyHint = (text: string) => (
|
||||||
|
<div className="py-10 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 通用插件行:图标 + 标题(名称 + 版本)+ 描述 + 右侧操作
|
||||||
|
const row = (key: string, icon: ReactNode, name: string, version: string, subtitle: string | undefined, right: ReactNode) => (
|
||||||
|
<div key={key} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
|
||||||
|
<span className="grid size-9 shrink-0 place-items-center rounded-lg text-base" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm font-medium" style={{ color: theme.node.text }}>
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
{versionTag(version)}
|
||||||
|
</div>
|
||||||
|
{subtitle ? (
|
||||||
|
<div className="mt-0.5 truncate text-xs" style={{ color: theme.node.muted }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const officialTab = (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs" style={{ color: theme.node.muted }}>
|
||||||
|
本项目官方插件,来自仓库注册表
|
||||||
|
</div>
|
||||||
|
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{officialError ? (
|
||||||
|
<div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||||
|
加载失败:{officialError}
|
||||||
|
</div>
|
||||||
|
) : loadingOfficial && official.length === 0 ? (
|
||||||
|
emptyHint("正在获取官方插件…")
|
||||||
|
) : official.length === 0 ? (
|
||||||
|
emptyHint("暂无官方插件")
|
||||||
|
) : (
|
||||||
|
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
|
||||||
|
{official.map((entry) => {
|
||||||
|
const record = recordById.get(entry.id);
|
||||||
|
return row(
|
||||||
|
entry.id,
|
||||||
|
entry.icon || <Puzzle className="size-4" />,
|
||||||
|
entry.name,
|
||||||
|
entry.version,
|
||||||
|
entry.description,
|
||||||
|
record ? (
|
||||||
|
installedControls(record)
|
||||||
|
) : (
|
||||||
|
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const localTab = <div className="thin-scrollbar max-h-[52vh] space-y-2 overflow-auto">{localPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>;
|
||||||
|
|
||||||
|
const thirdPartyTab = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js" value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
|
||||||
|
<Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}>
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint("还没有安装第三方插件") : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "official", label: "官方插件", children: officialTab },
|
||||||
|
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []),
|
||||||
|
{ key: "third", label: "第三方插件", children: thirdPartyTab },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
|
||||||
|
<span>插件代码会在当前页面内直接执行,可访问本地数据(包含 AI API Key)。请仅安装你信任来源的插件。</span>
|
||||||
|
</div>
|
||||||
|
<Tabs defaultActiveKey="official" items={tabs} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Empty, Input, Select, Tag } from "antd";
|
||||||
|
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
|
||||||
|
|
||||||
|
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||||
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||||
|
|
||||||
|
import type { InsertAssetPayload } from "./asset-picker-modal";
|
||||||
|
|
||||||
|
type PanelTab = "canvas" | "assets";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
nodes: CanvasNodeData[];
|
||||||
|
selectedNodeIds: Set<string>;
|
||||||
|
onFocusNode: (nodeId: string) => void;
|
||||||
|
onInsertAsset: (payload: InsertAssetPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_TYPE_ICON: Record<string, typeof Square> = {
|
||||||
|
[CanvasNodeType.Image]: ImageIcon,
|
||||||
|
[CanvasNodeType.Video]: Video,
|
||||||
|
[CanvasNodeType.Audio]: Music2,
|
||||||
|
[CanvasNodeType.Text]: Type,
|
||||||
|
[CanvasNodeType.Config]: Settings2,
|
||||||
|
[CanvasNodeType.Group]: Square,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
success: "#22c55e",
|
||||||
|
loading: "#f59e0b",
|
||||||
|
error: "#ef4444",
|
||||||
|
idle: "transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const [tab, setTab] = useState<PanelTab>("canvas");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom>
|
||||||
|
<div className="flex items-center gap-1 px-3 pt-3">
|
||||||
|
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
|
||||||
|
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 画布 Tab —— 列出节点,点击居中放大并选中
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NODE_FILTER_OPTIONS = [
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
{ label: "图片", value: CanvasNodeType.Image },
|
||||||
|
{ label: "视频", value: CanvasNodeType.Video },
|
||||||
|
{ label: "文本", value: CanvasNodeType.Text },
|
||||||
|
{ label: "音频", value: CanvasNodeType.Audio },
|
||||||
|
{ label: "配置", value: CanvasNodeType.Config },
|
||||||
|
{ label: "分组", value: CanvasNodeType.Group },
|
||||||
|
];
|
||||||
|
|
||||||
|
function nodePreviewText(node: CanvasNodeData) {
|
||||||
|
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
|
||||||
|
return getNodeDefinition(node.type)?.title || node.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = keyword.trim().toLowerCase();
|
||||||
|
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
|
||||||
|
}, [nodes, keyword, typeFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
|
||||||
|
<span className="text-xs font-medium opacity-60">画布元素</span>
|
||||||
|
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
|
||||||
|
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pb-2.5">
|
||||||
|
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||||
|
{filtered.length ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{filtered.map((node) => {
|
||||||
|
const Icon = NODE_TYPE_ICON[node.type] || FileText;
|
||||||
|
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
|
||||||
|
const active = selectedNodeIds.has(node.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={node.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFocusNode(node.id)}
|
||||||
|
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
|
||||||
|
style={active ? { background: theme.toolbar.activeBg } : undefined}
|
||||||
|
>
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}>
|
||||||
|
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
|
||||||
|
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
|
||||||
|
</span>
|
||||||
|
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-16 text-center text-sm opacity-40">画布暂无节点</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [
|
||||||
|
{ kind: "image", label: "图片", icon: ImageIcon },
|
||||||
|
{ kind: "video", label: "视频", icon: Video },
|
||||||
|
{ kind: "text", label: "文本", icon: FileText },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildInsertPayload(asset: Asset): InsertAssetPayload {
|
||||||
|
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title };
|
||||||
|
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height };
|
||||||
|
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
|
||||||
|
const assets = useAssetStore((state) => state.assets);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [tagFilter, setTagFilter] = useState<string>("all");
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = keyword.trim().toLowerCase();
|
||||||
|
return assets.filter((asset) => (tagFilter === "all" || (asset.tags || []).includes(tagFilter)) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)));
|
||||||
|
}, [assets, keyword, tagFilter]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{allTags.length ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5 px-3 pb-2">
|
||||||
|
<Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}>
|
||||||
|
全部
|
||||||
|
</Tag.CheckableTag>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}>
|
||||||
|
{tag}
|
||||||
|
</Tag.CheckableTag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||||
|
{groups.length ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const isCollapsed = collapsed[group.kind];
|
||||||
|
return (
|
||||||
|
<div key={group.kind}>
|
||||||
|
<button type="button" onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
|
||||||
|
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
|
||||||
|
<group.icon className="size-3.5" />
|
||||||
|
<span>{group.label}</span>
|
||||||
|
<span className="opacity-50">{group.items.length}</span>
|
||||||
|
</button>
|
||||||
|
{isCollapsed ? null : (
|
||||||
|
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
|
||||||
|
{group.items.map((asset) => (
|
||||||
|
<AssetCard key={asset.id} asset={asset} theme={theme} onClick={() => onInsert(buildInsertPayload(asset))} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) {
|
||||||
|
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className="group relative overflow-hidden rounded-lg border text-left transition hover:shadow-md" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||||
|
{cover ? <img src={cover} alt={asset.title} className="aspect-square w-full object-cover" /> : <div className="flex aspect-square items-center justify-center p-2 text-center text-[11px] leading-4 opacity-60">{asset.title}</div>}
|
||||||
|
<div className="truncate px-2 py-1.5 text-[11px] font-medium">{asset.title}</div>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-xs font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">插入</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button, Segmented, Switch } from "antd";
|
import { Button, Segmented, Switch } from "antd";
|
||||||
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||||
|
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function CanvasToolbar({
|
|||||||
onAddText,
|
onAddText,
|
||||||
onAddConfig,
|
onAddConfig,
|
||||||
onAddGroup,
|
onAddGroup,
|
||||||
|
onAddExtensionNode,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onUpload,
|
onUpload,
|
||||||
@@ -27,7 +29,6 @@ export function CanvasToolbar({
|
|||||||
onDeselect,
|
onDeselect,
|
||||||
onBackgroundModeChange,
|
onBackgroundModeChange,
|
||||||
onShowImageInfoChange,
|
onShowImageInfoChange,
|
||||||
onOpenMyAssets,
|
|
||||||
}: {
|
}: {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
@@ -40,6 +41,7 @@ export function CanvasToolbar({
|
|||||||
onAddText: () => void;
|
onAddText: () => void;
|
||||||
onAddConfig: () => void;
|
onAddConfig: () => void;
|
||||||
onAddGroup: () => void;
|
onAddGroup: () => void;
|
||||||
|
onAddExtensionNode: (type: string) => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onUpload: () => void;
|
onUpload: () => void;
|
||||||
@@ -48,9 +50,9 @@ export function CanvasToolbar({
|
|||||||
onDeselect: () => void;
|
onDeselect: () => void;
|
||||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||||
onShowImageInfoChange: (show: boolean) => void;
|
onShowImageInfoChange: (show: boolean) => void;
|
||||||
onOpenMyAssets: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const colorTheme = useThemeStore((state) => state.theme);
|
const colorTheme = useThemeStore((state) => state.theme);
|
||||||
const setTheme = useThemeStore((state) => state.setTheme);
|
const setTheme = useThemeStore((state) => state.setTheme);
|
||||||
const theme = canvasThemes[colorTheme];
|
const theme = canvasThemes[colorTheme];
|
||||||
@@ -58,13 +60,31 @@ export function CanvasToolbar({
|
|||||||
const [tipX, setTipX] = useState(0);
|
const [tipX, setTipX] = useState(0);
|
||||||
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
||||||
const [panelX, setPanelX] = useState(0);
|
const [panelX, setPanelX] = useState(0);
|
||||||
|
const [extensionsOpen, setExtensionsOpen] = useState(false);
|
||||||
|
const [extPanelX, setExtPanelX] = useState(0);
|
||||||
|
// 扩展(插件)节点,随注册表变化实时更新
|
||||||
|
useNodeRegistryVersion();
|
||||||
|
const extensionDefs = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false && getNodePluginId(def.type) !== "builtin");
|
||||||
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
|
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
|
||||||
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
|
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
|
||||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||||
const tip = hovered ? toolLabel(hovered) : "";
|
const tip = hovered ? toolLabel(hovered) : "";
|
||||||
|
|
||||||
|
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
|
||||||
|
useEffect(() => {
|
||||||
|
if (!extensionsOpen && !appearanceOpen) return;
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||||
|
setExtensionsOpen(false);
|
||||||
|
setAppearanceOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
}, [extensionsOpen, appearanceOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||||
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
||||||
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
|
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
|
||||||
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
||||||
@@ -95,13 +115,30 @@ export function CanvasToolbar({
|
|||||||
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
|
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
|
||||||
<Group className="size-4.5" />
|
<Group className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
{extensionDefs.length ? (
|
||||||
|
<ToolbarButton
|
||||||
|
id="tool-extensions"
|
||||||
|
label="扩展节点"
|
||||||
|
active={extensionsOpen}
|
||||||
|
hovered={hovered}
|
||||||
|
activeStyle={activeStyle}
|
||||||
|
hoverStyle={hoverStyle}
|
||||||
|
wrapRef={wrapRef}
|
||||||
|
onTipX={setTipX}
|
||||||
|
onHover={setHovered}
|
||||||
|
onClick={(event) => {
|
||||||
|
setExtPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||||
|
setAppearanceOpen(false);
|
||||||
|
setExtensionsOpen((value) => !value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Puzzle className="size-4.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
) : null}
|
||||||
|
<ToolbarButton id="tool-upload" label="上传资产" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||||
<Upload className="size-4.5" />
|
<Upload className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<Divider theme={theme} />
|
<Divider theme={theme} />
|
||||||
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
|
|
||||||
<FolderOpen className="size-4.5" />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
id="tool-style"
|
id="tool-style"
|
||||||
label="画布外观"
|
label="画布外观"
|
||||||
@@ -114,6 +151,7 @@ export function CanvasToolbar({
|
|||||||
onHover={setHovered}
|
onHover={setHovered}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||||
|
setExtensionsOpen(false);
|
||||||
setAppearanceOpen((value) => !value);
|
setAppearanceOpen((value) => !value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -133,6 +171,36 @@ export function CanvasToolbar({
|
|||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{extensionsOpen && extensionDefs.length ? (
|
||||||
|
<div
|
||||||
|
className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur"
|
||||||
|
style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
|
||||||
|
>
|
||||||
|
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50">扩展节点</div>
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
{extensionDefs.map((def) => (
|
||||||
|
<button
|
||||||
|
key={def.type}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm transition"
|
||||||
|
style={{ color: theme.toolbar.item }}
|
||||||
|
onMouseEnter={(event) => (event.currentTarget.style.background = theme.toolbar.itemHover)}
|
||||||
|
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
|
||||||
|
onClick={() => {
|
||||||
|
onAddExtensionNode(def.type);
|
||||||
|
setExtensionsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="grid size-7 shrink-0 place-items-center rounded-md text-base" style={{ background: theme.toolbar.itemHover }}>
|
||||||
|
{def.icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{def.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{appearanceOpen ? (
|
{appearanceOpen ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
|
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
|
||||||
@@ -287,8 +355,8 @@ function toolLabel(id: string) {
|
|||||||
if (id === "tool-audio") return "音频";
|
if (id === "tool-audio") return "音频";
|
||||||
if (id === "tool-config") return "生成配置";
|
if (id === "tool-config") return "生成配置";
|
||||||
if (id === "tool-group") return "组";
|
if (id === "tool-group") return "组";
|
||||||
if (id === "tool-upload") return "上传素材";
|
if (id === "tool-extensions") return "扩展节点";
|
||||||
if (id === "tool-assets") return "我的素材";
|
if (id === "tool-upload") return "上传资产";
|
||||||
if (id === "tool-style") return "画布外观";
|
if (id === "tool-style") return "画布外观";
|
||||||
if (id === "tool-delete") return "删除选中";
|
if (id === "tool-delete") return "删除选中";
|
||||||
if (id === "tool-clear") return "清空画布";
|
if (id === "tool-clear") return "清空画布";
|
||||||
|
|||||||
@@ -167,7 +167,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
|
// 阻止画布滚动导致页面滚动;但浮层(创建菜单/弹窗等)内允许原生滚动
|
||||||
|
const preventWheelScroll = (event: WheelEvent) => {
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
container.addEventListener("wheel", preventWheelScroll, { passive: false });
|
container.addEventListener("wheel", preventWheelScroll, { passive: false });
|
||||||
return () => container.removeEventListener("wheel", preventWheelScroll);
|
return () => container.removeEventListener("wheel", preventWheelScroll);
|
||||||
}, [containerRef]);
|
}, [containerRef]);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
|
||||||
|
|
||||||
|
import { NODE_SPECS } from "@/constant/canvas";
|
||||||
|
import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
|
||||||
|
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||||
|
import type { CanvasNodeDefinition, CanvasNodeResource } from "@/types/canvas-plugin";
|
||||||
|
|
||||||
|
// 内置节点的可扩展元数据(尺寸/初始 metadata 复用 NODE_SPECS)。
|
||||||
|
// 渲染仍由 canvas-node 内部渲染器负责,故不提供 Content。
|
||||||
|
function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
|
||||||
|
if (node.type === CanvasNodeType.Image && node.metadata?.content) return { kind: "image", url: node.metadata.content };
|
||||||
|
if (node.type === CanvasNodeType.Video && node.metadata?.content) return { kind: "video", url: node.metadata.content };
|
||||||
|
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return { kind: "audio", url: node.metadata.content };
|
||||||
|
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return { kind: "text", text: node.metadata.content || node.metadata.prompt };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconClass = "size-5";
|
||||||
|
|
||||||
|
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
|
||||||
|
{ type: CanvasNodeType.Text, title: "文本", icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
|
||||||
|
{ type: CanvasNodeType.Image, title: "图片", icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
|
||||||
|
{ type: CanvasNodeType.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
|
||||||
|
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
|
||||||
|
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
|
||||||
|
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
|
||||||
|
].map((def) => {
|
||||||
|
const spec = NODE_SPECS[def.type];
|
||||||
|
return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata };
|
||||||
|
});
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
export function registerBuiltinNodes() {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
registerNodeDefinitions(BUILTIN_DEFINITIONS, "builtin");
|
||||||
|
}
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
|
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
|
||||||
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
|
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
import { ModelPicker } from "@/components/model-picker";
|
||||||
import { fetchChannelModels } from "@/services/api/image";
|
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
|
||||||
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||||
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
||||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||||
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||||
|
|
||||||
type ModelGroup = {
|
type ModelGroup = {
|
||||||
capability: ModelCapability;
|
capability: ModelCapability;
|
||||||
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||||
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
|
|
||||||
defaultLabel: string;
|
defaultLabel: string;
|
||||||
optionsLabel: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WebdavDomainProgress = {
|
type WebdavDomainProgress = {
|
||||||
@@ -26,21 +24,16 @@ type WebdavDomainProgress = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const modelGroups: ModelGroup[] = [
|
const modelGroups: ModelGroup[] = [
|
||||||
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
|
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
|
||||||
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
|
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
|
||||||
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
|
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
|
||||||
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
|
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
|
||||||
];
|
|
||||||
|
|
||||||
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
|
||||||
{ label: "OpenAI", value: "openai" },
|
|
||||||
{ label: "Gemini", value: "gemini" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
|
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
|
||||||
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
|
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
|
||||||
canvas: "画布",
|
canvas: "画布",
|
||||||
assets: "我的素材",
|
assets: "我的资产",
|
||||||
"image-workbench": "生图工作台",
|
"image-workbench": "生图工作台",
|
||||||
"video-workbench": "视频创作台",
|
"video-workbench": "视频创作台",
|
||||||
};
|
};
|
||||||
@@ -58,7 +51,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
|
|||||||
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
||||||
const [loadingChannelId, setLoadingChannelId] = useState("");
|
const [editingChannelId, setEditingChannelId] = useState("");
|
||||||
const [testingWebdav, setTestingWebdav] = useState(false);
|
const [testingWebdav, setTestingWebdav] = useState(false);
|
||||||
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
||||||
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
||||||
@@ -70,8 +63,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||||
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
|
|
||||||
const webdavReady = Boolean(webdav.url.trim());
|
const webdavReady = Boolean(webdav.url.trim());
|
||||||
|
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
|
||||||
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
||||||
|
|
||||||
const saveConfig = (nextConfig: AiConfig) => {
|
const saveConfig = (nextConfig: AiConfig) => {
|
||||||
@@ -86,22 +79,12 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
clearPromptContinue();
|
clearPromptContinue();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateChannels = (channels: ModelChannel[]) => {
|
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
|
||||||
const nextConfig = withChannels(config, channels);
|
|
||||||
saveConfig(nextConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
|
|
||||||
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateChannelApiFormat = (channel: ModelChannel, apiFormat: ApiCallFormat) => {
|
|
||||||
const baseUrl = !channel.baseUrl.trim() || channel.baseUrl.trim() === defaultBaseUrlForApiFormat(channel.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : channel.baseUrl;
|
|
||||||
updateChannel(channel.id, { apiFormat, baseUrl });
|
|
||||||
};
|
|
||||||
|
|
||||||
const addChannel = () => {
|
const addChannel = () => {
|
||||||
updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]);
|
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` });
|
||||||
|
updateChannels([...config.channels, channel]);
|
||||||
|
setEditingChannelId(channel.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteChannel = (id: string) => {
|
const deleteChannel = (id: string) => {
|
||||||
@@ -112,46 +95,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshChannelModels = async (channel: ModelChannel) => {
|
const saveChannel = (channel: ModelChannel) => {
|
||||||
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item)));
|
||||||
message.error("请先填写该渠道的 Base URL 和 API Key");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoadingChannelId(channel.id);
|
|
||||||
try {
|
|
||||||
const models = await fetchChannelModels(channel);
|
|
||||||
updateChannels(config.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)));
|
|
||||||
message.success(`${channel.name} 模型列表已更新`);
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
|
||||||
} finally {
|
|
||||||
setLoadingChannelId("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshAllModels = async () => {
|
|
||||||
const runnable = config.channels.filter((channel) => channel.baseUrl.trim() && channel.apiKey.trim());
|
|
||||||
if (!runnable.length) {
|
|
||||||
message.error("请先填写至少一个渠道的 Base URL 和 API Key");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoadingChannelId("all");
|
|
||||||
try {
|
|
||||||
const entries = await Promise.all(runnable.map(async (channel) => [channel.id, await fetchChannelModels(channel)] as const));
|
|
||||||
const modelMap = new Map(entries);
|
|
||||||
updateChannels(config.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || [] } : channel)));
|
|
||||||
message.success("模型列表已更新");
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
|
||||||
} finally {
|
|
||||||
setLoadingChannelId("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
|
|
||||||
const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter(Boolean));
|
|
||||||
updateConfig(group.modelsKey, next);
|
|
||||||
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const testWebdav = async () => {
|
const testWebdav = async () => {
|
||||||
@@ -196,7 +141,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
try {
|
try {
|
||||||
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
|
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
|
||||||
updateWebdavConfig("lastSyncedAt", result.syncedAt);
|
updateWebdavConfig("lastSyncedAt", result.syncedAt);
|
||||||
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个素材,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
|
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个资产,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
|
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
|
||||||
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
|
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
|
||||||
@@ -215,107 +160,48 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
key: "channels",
|
key: "channels",
|
||||||
label: "渠道",
|
label: "渠道",
|
||||||
children: (
|
children: (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<div>
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="text-xs text-stone-500">每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。</div>
|
||||||
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-100">
|
|
||||||
<CircleAlert className="size-3.5 shrink-0" />
|
|
||||||
<span className="font-semibold">重要:</span>
|
|
||||||
<span>新增或拉取模型后,需要到“模型”Tab 选择可选项才会显示。</span>
|
|
||||||
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold text-amber-900 dark:text-amber-100" onClick={() => setActiveTab("models")}>
|
|
||||||
去模型设置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
|
|
||||||
拉取全部
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||||
新增渠道
|
新增渠道
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-3">
|
|
||||||
{config.channels.map((channel) => (
|
{config.channels.map((channel) => (
|
||||||
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||||
<div className="mt-1 text-xs text-stone-500">
|
<div className="mt-1 truncate text-xs text-stone-500">
|
||||||
{apiFormatLabel(channel.apiFormat)} · 已保存 {channel.models.length} 个模型
|
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} 个模型 · {channel.baseUrl || "未填写接口地址"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
|
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
|
||||||
拉取模型
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Form.Item label="渠道名称" className="mb-0">
|
|
||||||
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="调用格式" className="mb-0">
|
|
||||||
<Select value={channel.apiFormat} options={apiFormatOptions} onChange={(value: ApiCallFormat) => updateChannelApiFormat(channel, value)} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="Base URL" className="mb-0">
|
|
||||||
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="API Key" className="mb-0">
|
|
||||||
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="模型列表" className="mb-0 md:col-span-2">
|
|
||||||
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "models",
|
key: "preferences",
|
||||||
label: "模型",
|
label: "偏好设置",
|
||||||
children: (
|
children: (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<Form layout="vertical" requiredMark={false}>
|
||||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<div className="mb-2 text-sm font-semibold">默认模型</div>
|
||||||
<div className="text-sm font-semibold">默认模型和可选项</div>
|
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="mt-1 text-xs leading-5 text-stone-500">可选项决定各处下拉框展示哪些模型;同名模型会以括号里的渠道名区分。</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{modelGroups.map((group) => (
|
|
||||||
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
maxTagCount="responsive"
|
|
||||||
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
|
|
||||||
value={config[group.modelsKey]}
|
|
||||||
options={modelOptions}
|
|
||||||
onChange={(models) => updateCapabilityModels(group, models)}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{modelGroups.map((group) => (
|
{modelGroups.map((group) => (
|
||||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
|
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
|
||||||
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div className="mb-2 text-sm font-semibold">生成偏好</div>
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "preferences",
|
|
||||||
label: "生成偏好",
|
|
||||||
children: (
|
|
||||||
<Form layout="vertical" requiredMark={false}>
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -366,7 +252,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
<Cloud className="size-4" />
|
<Cloud className="size-4" />
|
||||||
WebDAV 同步
|
WebDAV 同步
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-stone-500">同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。</div>
|
<div className="mt-1 text-xs text-stone-500">同步画布、我的资产、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
|
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,6 +293,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ChannelEditorDrawer open={Boolean(editingChannel)} channel={editingChannel} onSave={saveChannel} onClose={() => setEditingChannelId("")} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,7 +307,7 @@ export function AppConfigModal() {
|
|||||||
title={
|
title={
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-semibold">配置与用户偏好</div>
|
<div className="text-lg font-semibold">配置与用户偏好</div>
|
||||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、模型选择和同步偏好</div>
|
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、默认模型和同步偏好</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
open={isConfigOpen}
|
open={isConfigOpen}
|
||||||
@@ -436,48 +323,33 @@ export function AppConfigModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
||||||
const models = modelOptionsFromChannels(channels);
|
const next: AiConfig = {
|
||||||
const imageModels = keepOrSuggest(config.imageModels, filterModelsByCapability(models, "image"), models);
|
|
||||||
const videoModels = keepOrSuggest(config.videoModels, filterModelsByCapability(models, "video"), models);
|
|
||||||
const textModels = keepOrSuggest(config.textModels, filterModelsByCapability(models, "text"), models);
|
|
||||||
const audioModels = keepOrSuggest(config.audioModels, filterModelsByCapability(models, "audio"), models);
|
|
||||||
return {
|
|
||||||
...config,
|
...config,
|
||||||
channels,
|
channels,
|
||||||
models,
|
models: modelOptionsFromChannels(channels),
|
||||||
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
||||||
apiKey: channels[0]?.apiKey || config.apiKey,
|
apiKey: channels[0]?.apiKey || config.apiKey,
|
||||||
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
||||||
imageModels,
|
};
|
||||||
videoModels,
|
return {
|
||||||
textModels,
|
...next,
|
||||||
audioModels,
|
imageModel: pickDefaultModel(next, "image", config.imageModel),
|
||||||
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
|
videoModel: pickDefaultModel(next, "video", config.videoModel),
|
||||||
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
|
textModel: pickDefaultModel(next, "text", config.textModel),
|
||||||
textModel: normalizeDefaultModel(config.textModel, textModels),
|
audioModel: pickDefaultModel(next, "audio", config.audioModel),
|
||||||
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function keepOrSuggest(current: string[], suggested: string[], allModels: string[]) {
|
function pickDefaultModel(config: AiConfig, capability: ModelCapability, current: string) {
|
||||||
const available = new Set(allModels);
|
const options = selectableModelsByCapability(config, capability);
|
||||||
const kept = uniqueModels(current).filter((model) => available.has(model));
|
const normalized = normalizeModelOptionValue(current, config.channels);
|
||||||
return kept.length ? kept : suggested;
|
return options.includes(normalized) ? normalized : options[0] || "";
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDefaultModel(value: string, options: string[]) {
|
|
||||||
if (options.includes(value)) return value;
|
|
||||||
return options[0] || value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImageCount(value: string) {
|
function normalizeImageCount(value: string) {
|
||||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueModels(models: string[]) {
|
|
||||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiFormatLabel(apiFormat: ApiCallFormat) {
|
function apiFormatLabel(apiFormat: ApiCallFormat) {
|
||||||
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
|
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
|
||||||
|
import { ListPlus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||||
|
import { ModelScriptEditor } from "./model-script-editor";
|
||||||
|
import { ModelSelectModal } from "./model-select-modal";
|
||||||
|
|
||||||
|
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
||||||
|
{ label: "OpenAI", value: "openai" },
|
||||||
|
{ label: "Gemini", value: "gemini" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
|
||||||
|
{ label: "生图", value: "image" },
|
||||||
|
{ label: "视频", value: "video" },
|
||||||
|
{ label: "文本", value: "text" },
|
||||||
|
{ label: "音频", value: "audio" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ScriptTarget = { name: string; capability: ModelCapability; value: string };
|
||||||
|
|
||||||
|
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
|
||||||
|
const [draft, setDraft] = useState<ModelChannel | null>(channel);
|
||||||
|
const [selectOpen, setSelectOpen] = useState(false);
|
||||||
|
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && channel) setDraft(channel);
|
||||||
|
}, [open, channel]);
|
||||||
|
|
||||||
|
if (!draft) return null;
|
||||||
|
|
||||||
|
const patch = (value: Partial<ModelChannel>) => setDraft((current) => (current ? { ...current, ...value } : current));
|
||||||
|
const setModels = (models: ChannelModel[]) => patch({ models });
|
||||||
|
|
||||||
|
const changeApiFormat = (apiFormat: ApiCallFormat) => {
|
||||||
|
const baseUrl = !draft.baseUrl.trim() || draft.baseUrl.trim() === defaultBaseUrlForApiFormat(draft.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : draft.baseUrl;
|
||||||
|
patch({ apiFormat, baseUrl });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySelection = (names: string[]) => {
|
||||||
|
const map = new Map(draft.models.map((model) => [model.name, model]));
|
||||||
|
setModels(names.map((name) => map.get(name) || { name, capability: guessCapability(name) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCapability = (name: string, capability: ModelCapability) => setModels(draft.models.map((model) => (model.name === name ? { ...model, capability } : model)));
|
||||||
|
const setScript = (name: string, script: string) => setModels(draft.models.map((model) => (model.name === name ? { ...model, script: script || undefined } : model)));
|
||||||
|
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
width={640}
|
||||||
|
title="编辑渠道"
|
||||||
|
onClose={onClose}
|
||||||
|
styles={{ body: { paddingTop: 16 } }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button type="primary" onClick={save}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm font-medium">渠道名称</span>
|
||||||
|
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm font-medium">协议</span>
|
||||||
|
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
|
||||||
|
</label>
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<span className="mb-1 block text-sm font-medium">接口地址</span>
|
||||||
|
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
|
||||||
|
</label>
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<span className="mb-1 block text-sm font-medium">API Key</span>
|
||||||
|
<Input.Password value={draft.apiKey} onChange={(event) => patch({ apiKey: event.target.value })} placeholder="sk-..." />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">渠道模型</div>
|
||||||
|
<div className="mt-0.5 text-xs text-stone-500">已选 {draft.models.length} 个;为每个模型指定能力并可自定义调用脚本。</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
|
||||||
|
选择模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-stone-200 p-2 dark:border-stone-800">
|
||||||
|
{draft.models.length ? (
|
||||||
|
draft.models.map((model) => (
|
||||||
|
<div key={model.name} className="flex flex-wrap items-center gap-3 rounded-md px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-stone-900/40">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm" title={model.name}>
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
|
||||||
|
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
|
||||||
|
{model.script ? "脚本已设" : "调用脚本"}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-8 text-center text-sm text-stone-500">点击「选择模型」拉取或手动增加模型。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelSelectModal open={selectOpen} channel={draft} selectedNames={draft.models.map((model) => model.name)} onConfirm={applySelection} onClose={() => setSelectOpen(false)} />
|
||||||
|
|
||||||
|
<ModelScriptEditor
|
||||||
|
open={Boolean(scriptTarget)}
|
||||||
|
capability={scriptTarget?.capability || "text"}
|
||||||
|
modelName={scriptTarget?.name || ""}
|
||||||
|
value={scriptTarget?.value || ""}
|
||||||
|
onSave={(script) => scriptTarget && setScript(scriptTarget.name, script)}
|
||||||
|
onClose={() => setScriptTarget(null)}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
|
import { Button, Modal } from "antd";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { PLUGIN_RETURNS, PLUGIN_TEMPLATES, PLUGIN_VARIABLES } from "@/services/api/model-plugin";
|
||||||
|
import type { ModelCapability } from "@/stores/use-config-store";
|
||||||
|
|
||||||
|
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
|
||||||
|
|
||||||
|
function isDarkMode() {
|
||||||
|
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDraft(value);
|
||||||
|
}, [open, value]);
|
||||||
|
|
||||||
|
const variables = PLUGIN_VARIABLES.filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold">
|
||||||
|
{capabilityLabels[capability]}
|
||||||
|
{modelName ? ` - ${modelName}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs font-normal text-stone-500">脚本是一段异步函数体,直接使用下方变量,最后 return 结果;留空则使用系统默认调用。</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={1080}
|
||||||
|
centered
|
||||||
|
onCancel={onClose}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{PLUGIN_TEMPLATES[capability].map((template) => (
|
||||||
|
<Button key={template.label} size="small" onClick={() => setDraft(template.script)}>
|
||||||
|
插入{template.label}模板
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button size="small" danger onClick={() => setDraft("")}>
|
||||||
|
恢复默认调用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
onSave(draft.trim());
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800">
|
||||||
|
<aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
|
||||||
|
<div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70">
|
||||||
|
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400">返回要求</div>
|
||||||
|
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">可用变量</span>
|
||||||
|
<span className="text-[10px] text-stone-400">点击插入</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraft((current) => (current ? `${current}\n${variable.name}` : variable.name))}
|
||||||
|
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||||
|
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
|
||||||
|
{variable.name}
|
||||||
|
</code>
|
||||||
|
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
|
||||||
|
<CodeMirror
|
||||||
|
value={draft}
|
||||||
|
onChange={setDraft}
|
||||||
|
height="100%"
|
||||||
|
theme={isDarkMode() ? "dark" : "light"}
|
||||||
|
extensions={[javascript()]}
|
||||||
|
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"}
|
||||||
|
style={{ height: "100%", fontSize: 13 }}
|
||||||
|
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { App, Button, Checkbox, Input, Modal, Tabs } from "antd";
|
||||||
|
import { RefreshCw, Search } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { fetchChannelModels } from "@/services/api/image";
|
||||||
|
import type { ModelChannel } from "@/stores/use-config-store";
|
||||||
|
|
||||||
|
// 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。
|
||||||
|
export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [existing, setExisting] = useState<string[]>([]);
|
||||||
|
const [fetched, setFetched] = useState<string[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [activeTab, setActiveTab] = useState("new");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [manual, setManual] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setExisting(selectedNames);
|
||||||
|
setFetched([]);
|
||||||
|
setSelected(new Set(selectedNames));
|
||||||
|
setActiveTab(selectedNames.length ? "existing" : "new");
|
||||||
|
setSearch("");
|
||||||
|
setManual("");
|
||||||
|
}, [open, selectedNames]);
|
||||||
|
|
||||||
|
const currentList = activeTab === "new" ? fetched : existing;
|
||||||
|
const visibleList = useMemo(() => {
|
||||||
|
const keyword = search.trim().toLowerCase();
|
||||||
|
return keyword ? currentList.filter((name) => name.toLowerCase().includes(keyword)) : currentList;
|
||||||
|
}, [currentList, search]);
|
||||||
|
const visibleSelectedCount = visibleList.filter((name) => selected.has(name)).length;
|
||||||
|
|
||||||
|
const toggle = (name: string, checked: boolean) =>
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (checked) next.add(name);
|
||||||
|
else next.delete(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectVisible = (checked: boolean) =>
|
||||||
|
setSelected((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
visibleList.forEach((name) => (checked ? next.add(name) : next.delete(name)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const addManual = () => {
|
||||||
|
const name = manual.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (!fetched.includes(name) && !existing.includes(name)) setFetched((current) => [name, ...current]);
|
||||||
|
setSelected((current) => new Set(current).add(name));
|
||||||
|
setManual("");
|
||||||
|
setActiveTab("new");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchModels = async () => {
|
||||||
|
if (!channel) return;
|
||||||
|
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
||||||
|
message.error("请先填写接口地址和 API Key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const models = await fetchChannelModels(channel);
|
||||||
|
setFetched(models);
|
||||||
|
setActiveTab("new");
|
||||||
|
message.success(`已拉取 ${models.length} 个模型`);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : "拉取模型失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const ordered = [...existing, ...fetched].filter((name, index, list) => list.indexOf(name) === index).filter((name) => selected.has(name));
|
||||||
|
onConfirm(ordered);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
width={880}
|
||||||
|
centered
|
||||||
|
onCancel={onClose}
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
选择渠道模型 <span className="ml-2 text-xs font-normal text-stone-500">已选择 {selected.size} / {new Set([...existing, ...fetched]).size}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button key="confirm" type="primary" onClick={confirm}>
|
||||||
|
确定
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索模型" prefix={<Search className="size-4 text-stone-400" />} allowClear />
|
||||||
|
<Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder="输入模型名称" />
|
||||||
|
<Button onClick={addManual}>增加模型</Button>
|
||||||
|
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}>
|
||||||
|
拉取模型列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-stone-500">如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
className="mt-3"
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{ key: "new", label: `新获取的模型 (${fetched.length})` },
|
||||||
|
{ key: "existing", label: `已有的模型 (${existing.length})` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-stone-500">当前列表已选择 {visibleSelectedCount} / {visibleList.length}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}>
|
||||||
|
全选当前列表
|
||||||
|
</Button>
|
||||||
|
<Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}>
|
||||||
|
取消当前列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleList.length ? (
|
||||||
|
<div className="grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-2">
|
||||||
|
{visibleList.map((name) => (
|
||||||
|
<Checkbox key={name} checked={selected.has(name)} onChange={(event) => toggle(name, event.target.checked)}>
|
||||||
|
<span className="truncate" title={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-sm text-stone-500">{activeTab === "new" ? "点击「拉取模型列表」获取上游模型,或手动增加模型名称。" : "暂无已选择的模型。"}</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { BookOpen, Keyboard, Settings2 } from "lucide-react";
|
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
|
||||||
|
|
||||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||||
import { GitHubLink } from "@/components/layout/github-link";
|
import { GitHubLink } from "@/components/layout/github-link";
|
||||||
@@ -14,9 +14,10 @@ type UserStatusActionsProps = {
|
|||||||
showConfig?: boolean;
|
showConfig?: boolean;
|
||||||
variant?: "default" | "canvas";
|
variant?: "default" | "canvas";
|
||||||
onOpenShortcuts?: () => void;
|
onOpenShortcuts?: () => void;
|
||||||
|
onOpenPlugins?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts }: UserStatusActionsProps) {
|
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const setTheme = useThemeStore((state) => state.setTheme);
|
const setTheme = useThemeStore((state) => state.setTheme);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
@@ -29,6 +30,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex shrink-0 items-center gap-1">
|
<div className="inline-flex shrink-0 items-center gap-1">
|
||||||
|
{onOpenPlugins ? (
|
||||||
|
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label="节点插件" title="节点插件">
|
||||||
|
<Puzzle className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档">
|
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档">
|
||||||
<BookOpen className="size-4" />
|
<BookOpen className="size-4" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
|||||||
|
|
||||||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||||||
if (capability && config.models.length) return "请先在上方配置可选模型";
|
if (capability && config.models.length) return `请先在渠道里为${label}指定模型`;
|
||||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CanvasNodeType } from "@/types/canvas";
|
import { CanvasNodeType } from "@/types/canvas";
|
||||||
import type { CanvasNodeMetadata } from "@/types/canvas";
|
import type { CanvasNodeMetadata } from "@/types/canvas";
|
||||||
|
import { getNodeSpec as getRegistryNodeSpec } from "@/lib/canvas/node-registry";
|
||||||
|
|
||||||
type CanvasNodeSpec = {
|
type CanvasNodeSpec = {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -44,6 +45,9 @@ export const NODE_SPECS = {
|
|||||||
},
|
},
|
||||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||||
|
|
||||||
export function getNodeSpec(type: CanvasNodeType) {
|
// 内置类型返回内置 spec;插件类型从注册表解析
|
||||||
return NODE_SPECS[type];
|
export function getNodeSpec(type: string) {
|
||||||
|
if ((Object.values(CanvasNodeType) as string[]).includes(type)) return NODE_SPECS[type as CanvasNodeType];
|
||||||
|
const spec = getRegistryNodeSpec(type);
|
||||||
|
return { width: spec.width, height: spec.height, title: spec.title, metadata: spec.metadata };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export const APP_VERSION = __APP_VERSION__ || "dev";
|
export const APP_VERSION = __APP_VERSION__ || "dev";
|
||||||
|
|
||||||
export const DOCS_URL = import.meta.env.VITE_DOC_URL || "https://docs.canvas.best";
|
export const DOCS_URL = import.meta.env.VITE_DOC_URL || "https://docs.canvas.best";
|
||||||
|
|
||||||
|
// 官方插件清单地址:CI 发布到 plugins-dist 分支,经 jsDelivr 远程拉取;可用环境变量覆盖成自建来源
|
||||||
|
export const PLUGIN_REGISTRY_URL = import.meta.env.VITE_PLUGIN_REGISTRY_URL || "https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json";
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const navigationTools = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "assets",
|
slug: "assets",
|
||||||
label: "我的素材",
|
label: "我的资产",
|
||||||
icon: Images,
|
icon: Images,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { imageAspectOptions, imageQualityOptions } from "@/components/image-sett
|
|||||||
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
||||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, useConfigStore } from "@/stores/use-config-store";
|
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
|
||||||
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
||||||
|
|
||||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。
|
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、资产增删查等)。
|
||||||
// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。
|
// 这些工具的数据都在浏览器本地(localforage / zustand),因此由本模块直接读写对应 store 后返回结果。
|
||||||
|
|
||||||
export const SITE_TOOL_NAMES = [
|
export const SITE_TOOL_NAMES = [
|
||||||
@@ -36,8 +36,8 @@ export const SITE_TOOL_LABELS: Record<SiteToolName, string> = {
|
|||||||
workbench_video_get_config: "视频配置",
|
workbench_video_get_config: "视频配置",
|
||||||
workbench_video_generate: "视频创作台生成",
|
workbench_video_generate: "视频创作台生成",
|
||||||
prompts_search: "搜索提示词",
|
prompts_search: "搜索提示词",
|
||||||
assets_list: "素材列表",
|
assets_list: "资产列表",
|
||||||
assets_add: "添加素材",
|
assets_add: "添加资产",
|
||||||
};
|
};
|
||||||
|
|
||||||
type SiteToolInput = Record<string, unknown>;
|
type SiteToolInput = Record<string, unknown>;
|
||||||
@@ -87,7 +87,7 @@ function getImageConfig() {
|
|||||||
const model = config.imageModel || config.model;
|
const model = config.imageModel || config.model;
|
||||||
return {
|
return {
|
||||||
current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" },
|
current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" },
|
||||||
models: config.imageModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
models: selectableModelsByCapability(config, "image").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||||
qualityOptions: imageQualityOptions,
|
qualityOptions: imageQualityOptions,
|
||||||
sizeOptions: imageAspectOptions,
|
sizeOptions: imageAspectOptions,
|
||||||
countRange: { min: 1, max: 15 },
|
countRange: { min: 1, max: 15 },
|
||||||
@@ -135,7 +135,7 @@ function getVideoConfig() {
|
|||||||
generateAudio: config.videoGenerateAudio !== "false",
|
generateAudio: config.videoGenerateAudio !== "false",
|
||||||
watermark: config.videoWatermark === "true",
|
watermark: config.videoWatermark === "true",
|
||||||
},
|
},
|
||||||
models: config.videoModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
models: selectableModelsByCapability(config, "video").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||||
sizeOptions: videoSizeOptions,
|
sizeOptions: videoSizeOptions,
|
||||||
secondsOptions: videoSecondOptions,
|
secondsOptions: videoSecondOptions,
|
||||||
resolutionOptions: videoResolutionOptions,
|
resolutionOptions: videoResolutionOptions,
|
||||||
@@ -194,7 +194,7 @@ async function searchPrompts(input: SiteToolInput) {
|
|||||||
|
|
||||||
function listAssets(input: SiteToolInput) {
|
function listAssets(input: SiteToolInput) {
|
||||||
const { assets, hydrated } = useAssetStore.getState();
|
const { assets, hydrated } = useAssetStore.getState();
|
||||||
if (!hydrated) throw new Error("素材还在加载中,请稍后重试");
|
if (!hydrated) throw new Error("资产还在加载中,请稍后重试");
|
||||||
const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all";
|
const kind = input.kind === "text" || input.kind === "image" || input.kind === "video" ? input.kind : "all";
|
||||||
const keyword = String(input.keyword || "").trim().toLowerCase();
|
const keyword = String(input.keyword || "").trim().toLowerCase();
|
||||||
const filtered = assets.filter((asset) => {
|
const filtered = assets.filter((asset) => {
|
||||||
@@ -221,7 +221,7 @@ function listAssets(input: SiteToolInput) {
|
|||||||
async function addAsset(input: SiteToolInput) {
|
async function addAsset(input: SiteToolInput) {
|
||||||
const kind = input.kind;
|
const kind = input.kind;
|
||||||
const title = String(input.title || "").trim();
|
const title = String(input.title || "").trim();
|
||||||
if (!title) throw new Error("请提供素材标题 title");
|
if (!title) throw new Error("请提供资产标题 title");
|
||||||
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
|
const tags = Array.isArray(input.tags) ? input.tags.filter((tag): tag is string => typeof tag === "string") : [];
|
||||||
const source = typeof input.source === "string" ? input.source : "Agent";
|
const source = typeof input.source === "string" ? input.source : "Agent";
|
||||||
const note = typeof input.note === "string" ? input.note : undefined;
|
const note = typeof input.note === "string" ? input.note : undefined;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
import { getNodeSpec } from "@/constant/canvas";
|
import { getNodeSpec, isRegisteredNodeType } from "@/lib/canvas/node-registry";
|
||||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ViewportTransform } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ViewportTransform } from "@/types/canvas";
|
||||||
|
|
||||||
export type CanvasAgentOp =
|
export type CanvasAgentOp =
|
||||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeType; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeType }
|
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||||
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||||
@@ -42,7 +42,7 @@ export function applyCanvasAgentOps(snapshot: CanvasAgentSnapshot, ops?: CanvasA
|
|||||||
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
(Array.isArray(ops) ? ops : []).forEach((op, index) => {
|
||||||
if (!op?.type) return;
|
if (!op?.type) return;
|
||||||
if (op.type === "add_node") {
|
if (op.type === "add_node") {
|
||||||
const nodeType = Object.values(CanvasNodeType).includes(op.nodeType as CanvasNodeType) ? op.nodeType! : CanvasNodeType.Text;
|
const nodeType = op.nodeType && isRegisteredNodeType(op.nodeType) ? op.nodeType : CanvasNodeType.Text;
|
||||||
const spec = getNodeSpec(nodeType);
|
const spec = getNodeSpec(nodeType);
|
||||||
const node: CanvasNodeData = {
|
const node: CanvasNodeData = {
|
||||||
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
id: op.id || `${nodeType}-${Date.now()}-${index}`,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import localforage from "localforage";
|
||||||
|
|
||||||
|
import type { PluginStorage } from "@/types/canvas-plugin";
|
||||||
|
|
||||||
|
// 画布内轻量事件总线,供节点/插件互相通信
|
||||||
|
type Handler = (payload: unknown) => void;
|
||||||
|
const handlers = new Map<string, Set<Handler>>();
|
||||||
|
|
||||||
|
export function emitCanvasEvent(event: string, payload?: unknown) {
|
||||||
|
handlers.get(event)?.forEach((handler) => {
|
||||||
|
try {
|
||||||
|
handler(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[canvas-event] handler for "${event}" failed`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onCanvasEvent(event: string, handler: Handler) {
|
||||||
|
let set = handlers.get(event);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
handlers.set(event, set);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
return () => set!.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件私有存储,按 pluginId 命名空间隔离
|
||||||
|
const stores = new Map<string, LocalForage>();
|
||||||
|
|
||||||
|
export function createPluginStorage(pluginId: string): PluginStorage {
|
||||||
|
let store = stores.get(pluginId);
|
||||||
|
if (!store) {
|
||||||
|
store = localforage.createInstance({ name: "infinite-canvas-plugins", storeName: pluginId });
|
||||||
|
stores.set(pluginId, store);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
get: (key) => store!.getItem(key),
|
||||||
|
set: async (key, value) => {
|
||||||
|
await store!.setItem(key, value);
|
||||||
|
},
|
||||||
|
remove: async (key) => {
|
||||||
|
await store!.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||||
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||||
|
|
||||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||||
@@ -71,7 +72,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
|||||||
label,
|
label,
|
||||||
title: node.title || label,
|
title: node.title || label,
|
||||||
previewUrl: node.metadata?.content,
|
previewUrl: node.metadata?.content,
|
||||||
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
|
text: resourceText(node),
|
||||||
active,
|
active,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -89,10 +90,17 @@ function isResourceNode(node: CanvasNodeData) {
|
|||||||
return Boolean(resourceKind(node));
|
return Boolean(resourceKind(node));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceText(node: CanvasNodeData): string | undefined {
|
||||||
|
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt;
|
||||||
|
const resource = getNodeDefinition(node.type)?.resource?.(node);
|
||||||
|
return resource?.kind === "text" ? resource.text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||||
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||||
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||||
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||||
return null;
|
// 插件节点通过 definition.resource 声明可作为输入
|
||||||
|
return getNodeDefinition(node.type)?.resource?.(node)?.kind || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import type { CanvasNodeDefinition } from "@/types/canvas-plugin";
|
||||||
|
import { CanvasNodeType } from "@/types/canvas";
|
||||||
|
|
||||||
|
const definitions = new Map<string, CanvasNodeDefinition>();
|
||||||
|
const ownerByType = new Map<string, string>(); // type -> pluginId(内置为 "builtin")
|
||||||
|
|
||||||
|
// 注册表版本号,注册/卸载时自增,驱动创建菜单等 UI 重渲染
|
||||||
|
export const useNodeRegistryVersion = create<{ version: number }>(() => ({ version: 0 }));
|
||||||
|
function bump() {
|
||||||
|
useNodeRegistryVersion.setState((state) => ({ version: state.version + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], pluginId = "builtin") {
|
||||||
|
defs.forEach((def) => {
|
||||||
|
definitions.set(def.type, def);
|
||||||
|
ownerByType.set(def.type, pluginId);
|
||||||
|
});
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterPluginNodes(pluginId: string) {
|
||||||
|
for (const [type, owner] of ownerByType) {
|
||||||
|
if (owner !== pluginId) continue;
|
||||||
|
definitions.delete(type);
|
||||||
|
ownerByType.delete(type);
|
||||||
|
}
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeDefinition(type: string) {
|
||||||
|
return definitions.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodePluginId(type: string) {
|
||||||
|
return ownerByType.get(type) || "builtin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNodeDefinitions() {
|
||||||
|
return Array.from(definitions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegisteredNodeType(type: string) {
|
||||||
|
return definitions.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_SPEC = { width: 340, height: 240, title: "节点", metadata: {} as CanvasNodeDefinition["defaultMetadata"] };
|
||||||
|
|
||||||
|
// 提供默认尺寸/标题/初始 metadata,createCanvasNode 与 agent-ops 复用
|
||||||
|
export function getNodeSpec(type: string) {
|
||||||
|
const def = definitions.get(type);
|
||||||
|
if (!def) return FALLBACK_SPEC;
|
||||||
|
return { width: def.defaultSize.width, height: def.defaultSize.height, title: def.title, metadata: def.defaultMetadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBuiltinNodeType(type: string) {
|
||||||
|
return (Object.values(CanvasNodeType) as string[]).includes(type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { registerNodeDefinitions, unregisterPluginNodes } from "@/lib/canvas/node-registry";
|
||||||
|
import { getPluginRuntime } from "@/lib/canvas/plugin-runtime";
|
||||||
|
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||||
|
import type { CanvasPlugin } from "@/types/canvas-plugin";
|
||||||
|
|
||||||
|
const cleanups = new Map<string, () => void>();
|
||||||
|
|
||||||
|
// 远程插件默认导出可以是 CanvasPlugin,或接收 runtime 返回 CanvasPlugin 的工厂
|
||||||
|
// (工厂形式用 runtime.React,无需 bundle 自带 React)
|
||||||
|
async function evaluatePluginSource(source: string): Promise<CanvasPlugin> {
|
||||||
|
const blob = new Blob([source], { type: "text/javascript" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown; plugin?: unknown };
|
||||||
|
const exported = mod.default ?? mod.plugin;
|
||||||
|
const plugin = typeof exported === "function" ? (exported as (runtime: unknown) => unknown)(getPluginRuntime()) : exported;
|
||||||
|
assertPlugin(plugin);
|
||||||
|
return plugin;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPlugin(plugin: unknown): asserts plugin is CanvasPlugin {
|
||||||
|
const value = plugin as Partial<CanvasPlugin> | null;
|
||||||
|
if (!value || typeof value !== "object") throw new Error("插件未导出有效对象");
|
||||||
|
if (!value.id || !Array.isArray(value.nodes) || !value.nodes.length) throw new Error("插件缺少 id 或 nodes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activatePlugin(plugin: CanvasPlugin) {
|
||||||
|
registerNodeDefinitions(plugin.nodes, plugin.id);
|
||||||
|
const runtime = getPluginRuntime();
|
||||||
|
const disposers: Array<() => void> = [];
|
||||||
|
// 插件声明的样式:启用时注入,禁用/卸载时清理
|
||||||
|
if (plugin.css) disposers.push(runtime.injectCSS(plugin.css, plugin.id));
|
||||||
|
const cleanup = plugin.setup?.(runtime);
|
||||||
|
if (typeof cleanup === "function") disposers.push(cleanup);
|
||||||
|
if (disposers.length) cleanups.set(plugin.id, () => disposers.forEach((dispose) => dispose()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivatePlugin(pluginId: string) {
|
||||||
|
cleanups.get(pluginId)?.();
|
||||||
|
cleanups.delete(pluginId);
|
||||||
|
unregisterPluginNodes(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPluginSource(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`下载失败 (HTTP ${response.status})`);
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加缓存穿透参数,配合 watch 构建拿到最新产物
|
||||||
|
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);
|
||||||
|
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 });
|
||||||
|
activatePlugin(plugin);
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlugin(record: InstalledPlugin) {
|
||||||
|
return installPluginFromUrl(record.url, { official: record.official });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
|
||||||
|
usePluginStore.getState().setEnabled(record.id, enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
deactivatePlugin(record.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 本地插件启用时按 url 重新拉取,拿到最新构建(缓存 source 可能已过期)
|
||||||
|
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
|
||||||
|
const plugin = await evaluatePluginSource(source);
|
||||||
|
activatePlugin(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uninstallPlugin(id: string) {
|
||||||
|
deactivatePlugin(id);
|
||||||
|
usePluginStore.getState().remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
// 应用启动时加载已安装且启用的插件
|
||||||
|
export async function ensurePluginsLoaded() {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
await usePluginStore.persist.rehydrate();
|
||||||
|
await loadLocalPlugins(); // 先发现本地插件(默认关闭),再统一按 enabled 激活
|
||||||
|
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
|
||||||
|
await Promise.all(
|
||||||
|
records.map(async (record) => {
|
||||||
|
try {
|
||||||
|
// 本地插件用最新产物,其余用缓存的源码
|
||||||
|
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
|
||||||
|
activatePlugin(await evaluatePluginSource(source));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[plugin] 加载失败: ${record.id}`, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await loadDevPlugins();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
|
||||||
|
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
|
||||||
|
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
|
||||||
|
async function loadLocalPlugins() {
|
||||||
|
let urls: unknown;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/plugins/index.json");
|
||||||
|
if (!response.ok) return;
|
||||||
|
urls = await response.json();
|
||||||
|
} catch {
|
||||||
|
return; // 无本地清单(如生产环境未构建插件)则跳过
|
||||||
|
}
|
||||||
|
if (!Array.isArray(urls) || !urls.length) return;
|
||||||
|
const store = usePluginStore.getState();
|
||||||
|
await Promise.all(
|
||||||
|
urls.map(async (url: string) => {
|
||||||
|
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 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
|
||||||
|
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
|
||||||
|
async function loadDevPlugins() {
|
||||||
|
const raw = import.meta.env.VITE_DEV_PLUGINS;
|
||||||
|
if (!raw) return;
|
||||||
|
const urls = raw.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
|
await Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const source = await fetchPluginSource(withCacheBust(url));
|
||||||
|
const plugin = await evaluatePluginSource(source);
|
||||||
|
deactivatePlugin(plugin.id);
|
||||||
|
activatePlugin(plugin);
|
||||||
|
console.info(`[plugin] dev 插件已加载: ${plugin.id} (${url})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[plugin] dev 插件加载失败: ${url}`, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createPluginStorage, emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||||
|
import { getNodePluginId } from "@/lib/canvas/node-registry";
|
||||||
|
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||||
|
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 {
|
||||||
|
const storage = createPluginStorage(getNodePluginId(node.type));
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
theme,
|
||||||
|
scale,
|
||||||
|
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
|
||||||
|
updateNode: (patch) => host.updateNode(node.id, patch),
|
||||||
|
getNode: (id) => host.getNode(id),
|
||||||
|
getNodes: () => host.getNodes(),
|
||||||
|
getConnections: () => host.getConnections(),
|
||||||
|
getUpstream: () => host.getUpstream(node.id),
|
||||||
|
getDownstream: () => host.getDownstream(node.id),
|
||||||
|
applyOps: (ops) => host.applyOps(ops),
|
||||||
|
emit: (event, payload) => emitCanvasEvent(event, payload),
|
||||||
|
on: (event, handler) => onCanvasEvent(event, handler),
|
||||||
|
storage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { PLUGIN_REGISTRY_URL } from "@/constant/env";
|
||||||
|
|
||||||
|
// 官方插件清单里的一条(entry 已解析成绝对 URL)
|
||||||
|
export type OfficialPluginEntry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawEntry = { id?: string; name?: string; version?: string; description?: string; icon?: string; entry?: string; url?: string };
|
||||||
|
type RawManifest = { plugins?: RawEntry[] };
|
||||||
|
|
||||||
|
// 拉取官方插件清单;entry(相对文件名)按清单地址解析成绝对 URL,再走既有 URL 安装流程
|
||||||
|
export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY_URL): Promise<OfficialPluginEntry[]> {
|
||||||
|
const response = await fetch(registryUrl, { headers: { accept: "application/json" } });
|
||||||
|
if (!response.ok) throw new Error(`获取官方插件列表失败 (HTTP ${response.status})`);
|
||||||
|
const data = (await response.json()) as RawManifest;
|
||||||
|
const list = Array.isArray(data?.plugins) ? data.plugins : [];
|
||||||
|
return list
|
||||||
|
.filter((item): item is RawEntry & { id: string } => Boolean(item && item.id && (item.entry || item.url)))
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || item.id,
|
||||||
|
version: item.version || "0.0.0",
|
||||||
|
description: item.description,
|
||||||
|
icon: item.icon,
|
||||||
|
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { emitCanvasEvent, onCanvasEvent } from "@/lib/canvas/canvas-event-bus";
|
||||||
|
import type { CanvasPluginApp } from "@/types/canvas-plugin";
|
||||||
|
|
||||||
|
// 插件运行时:远程插件通过它拿到宿主的 React 实例,避免多份 React 实例
|
||||||
|
export type PluginRuntime = CanvasPluginApp & {
|
||||||
|
React: typeof React;
|
||||||
|
jsx: typeof React.createElement;
|
||||||
|
Fragment: typeof React.Fragment;
|
||||||
|
injectCSS: (css: string, key?: string) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
// 注入插件样式:同 key 覆盖旧样式,返回移除函数
|
||||||
|
function injectCSS(css: string, key?: string) {
|
||||||
|
const id = key ? `canvas-plugin-style-${key}` : undefined;
|
||||||
|
if (id) document.getElementById(id)?.remove();
|
||||||
|
const style = document.createElement("style");
|
||||||
|
if (id) style.id = id;
|
||||||
|
style.dataset.canvasPluginStyle = "true";
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
return () => style.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
runtime = {
|
||||||
|
React,
|
||||||
|
jsx: React.createElement,
|
||||||
|
Fragment: React.Fragment,
|
||||||
|
injectCSS,
|
||||||
|
version: typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev",
|
||||||
|
emit: emitCanvasEvent,
|
||||||
|
on: onCanvasEvent,
|
||||||
|
};
|
||||||
|
(window as unknown as { InfiniteCanvasRuntime?: PluginRuntime }).InfiniteCanvasRuntime = runtime;
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@ export function buildSeedancePromptText(prompt: string, images: ReferenceImage[]
|
|||||||
];
|
];
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
if (!labels.length) return text;
|
if (!labels.length) return text;
|
||||||
return `参考素材编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
return `参考资产编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||||
@@ -166,4 +166,4 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
|
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸资产请使用火山授权 asset:// 资产。";
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function exportAssets(assets: Asset[]) {
|
|||||||
|
|
||||||
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
|
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
|
||||||
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||||
saveAs(zip, "我的素材.zip");
|
saveAs(zip, "我的资产.zip");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readAssetPackage(file: File) {
|
export async function readAssetPackage(file: File) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function AssetsPage() {
|
|||||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
message.success(editingAsset ? "资产已更新" : "资产已保存");
|
||||||
setIsAssetOpen(false);
|
setIsAssetOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ export default function AssetsPage() {
|
|||||||
|
|
||||||
const exportAllAssets = async () => {
|
const exportAllAssets = async () => {
|
||||||
if (!validAssets.length) {
|
if (!validAssets.length) {
|
||||||
message.warning("暂无素材可导出");
|
message.warning("暂无资产可导出");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await exportAssets(validAssets);
|
await exportAssets(validAssets);
|
||||||
@@ -170,9 +170,9 @@ export default function AssetsPage() {
|
|||||||
delete payload.updatedAt;
|
delete payload.updatedAt;
|
||||||
addAsset(payload as Parameters<typeof addAsset>[0]);
|
addAsset(payload as Parameters<typeof addAsset>[0]);
|
||||||
});
|
});
|
||||||
message.success(`已导入 ${importedAssets.length} 个素材`);
|
message.success(`已导入 ${importedAssets.length} 个资产`);
|
||||||
} catch {
|
} catch {
|
||||||
message.error("导入失败,请选择有效的素材压缩包");
|
message.error("导入失败,请选择有效的资产压缩包");
|
||||||
} finally {
|
} finally {
|
||||||
if (assetInputRef.current) assetInputRef.current.value = "";
|
if (assetInputRef.current) assetInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ export default function AssetsPage() {
|
|||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
if (!deletingAsset) return;
|
if (!deletingAsset) return;
|
||||||
removeAsset(deletingAsset.id);
|
removeAsset(deletingAsset.id);
|
||||||
message.success("素材已删除");
|
message.success("资产已删除");
|
||||||
setDeletingAsset(null);
|
setDeletingAsset(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ export default function AssetsPage() {
|
|||||||
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
||||||
<div className="pb-8">
|
<div className="pb-8">
|
||||||
<div className="mx-auto max-w-5xl text-center">
|
<div className="mx-auto max-w-5xl text-center">
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的素材</h1>
|
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的资产</h1>
|
||||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,21 +239,21 @@ export default function AssetsPage() {
|
|||||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||||
onClick={() => void exportAllAssets()}
|
onClick={() => void exportAllAssets()}
|
||||||
>
|
>
|
||||||
导出素材
|
导出资产
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||||
onClick={() => assetInputRef.current?.click()}
|
onClick={() => assetInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
导入素材
|
导入资产
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
|
||||||
onClick={openCreate}
|
onClick={openCreate}
|
||||||
>
|
>
|
||||||
新增素材
|
新增资产
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,7 +267,7 @@ export default function AssetsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到资产" className="py-20" /> : null}
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Pagination
|
<Pagination
|
||||||
@@ -285,7 +285,7 @@ export default function AssetsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
<Modal title={editingAsset ? "编辑资产" : "新增资产"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||||
<Form.Item name="kind" label="类型">
|
<Form.Item name="kind" label="类型">
|
||||||
@@ -298,7 +298,7 @@ export default function AssetsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
<Input size="large" placeholder="给资产起一个容易检索的名字" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="coverUrl" label="封面 URL">
|
<Form.Item name="coverUrl" label="封面 URL">
|
||||||
<Space.Compact className="w-full">
|
<Space.Compact className="w-full">
|
||||||
@@ -321,7 +321,7 @@ export default function AssetsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{formKind === "text" ? (
|
{formKind === "text" ? (
|
||||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本资产" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : (
|
) : (
|
||||||
<Form.Item label="图片内容" required>
|
<Form.Item label="图片内容" required>
|
||||||
@@ -352,7 +352,7 @@ export default function AssetsPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Typography.Text strong ellipsis className="block">
|
<Typography.Text strong ellipsis className="block">
|
||||||
{title || "未命名素材"}
|
{title || "未命名资产"}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{tags.length ? (
|
{tags.length ? (
|
||||||
@@ -395,8 +395,8 @@ export default function AssetsPage() {
|
|||||||
|
|
||||||
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
|
||||||
|
|
||||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
<Modal title="删除资产" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
确定删除「{deletingAsset?.title}」吗?删除后会从我的资产中移除。
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -474,7 +474,7 @@ function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { as
|
|||||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
||||||
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
||||||
return (
|
return (
|
||||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
<Drawer title="资产详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||||
{asset ? (
|
{asset ? (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{cover ? (
|
{cover ? (
|
||||||
|
|||||||
@@ -38,11 +38,18 @@ import { CanvasNode } from "@/components/canvas/canvas-node";
|
|||||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
|
||||||
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||||
|
import { CanvasSidePanel } from "@/components/canvas/canvas-side-panel";
|
||||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||||
import { useAgentStore } from "@/stores/use-agent-store";
|
import { useAgentStore } from "@/stores/use-agent-store";
|
||||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||||
|
import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||||
|
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||||
|
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
||||||
|
import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
|
||||||
|
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
||||||
|
import type { CanvasPluginHost } from "@/types/canvas-plugin";
|
||||||
import {
|
import {
|
||||||
CanvasNodeType,
|
CanvasNodeType,
|
||||||
type CanvasAssistantImage,
|
type CanvasAssistantImage,
|
||||||
@@ -51,6 +58,7 @@ import {
|
|||||||
type CanvasImageGenerationType,
|
type CanvasImageGenerationType,
|
||||||
type CanvasNodeData,
|
type CanvasNodeData,
|
||||||
type CanvasNodeMetadata,
|
type CanvasNodeMetadata,
|
||||||
|
type CanvasNodeTypeId,
|
||||||
type ConnectionHandle,
|
type ConnectionHandle,
|
||||||
type ContextMenuState,
|
type ContextMenuState,
|
||||||
type Position,
|
type Position,
|
||||||
@@ -60,6 +68,9 @@ import {
|
|||||||
import type { ReferenceImage } from "@/types/image";
|
import type { ReferenceImage } from "@/types/image";
|
||||||
import type { ReferenceAudio } from "@/types/media";
|
import type { ReferenceAudio } from "@/types/media";
|
||||||
|
|
||||||
|
// 内置节点注册到统一注册表(模块加载时执行一次)
|
||||||
|
registerBuiltinNodes();
|
||||||
|
|
||||||
type CanvasClipboard = {
|
type CanvasClipboard = {
|
||||||
nodes: CanvasNodeData[];
|
nodes: CanvasNodeData[];
|
||||||
connections: CanvasConnection[];
|
connections: CanvasConnection[];
|
||||||
@@ -104,7 +115,7 @@ const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用
|
|||||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||||
|
|
||||||
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||||
const spec = getNodeSpec(type);
|
const spec = getNodeSpec(type);
|
||||||
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
@@ -212,11 +223,23 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: CanvasNodeType) => void; onClose: () => void }) {
|
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
useNodeRegistryVersion();
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
|
||||||
|
// 点击菜单外的空白处自动关闭
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||||
|
}, [onClose]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
|
ref={menuRef}
|
||||||
|
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
|
||||||
data-canvas-no-zoom
|
data-canvas-no-zoom
|
||||||
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
@@ -228,12 +251,9 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本" onClick={() => onCreate(CanvasNodeType.Text)} />
|
{definitions.map((def) => (
|
||||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片" onClick={() => onCreate(CanvasNodeType.Image)} />
|
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
|
||||||
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频" onClick={() => onCreate(CanvasNodeType.Video)} />
|
))}
|
||||||
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
|
||||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="生成配置" onClick={() => onCreate(CanvasNodeType.Config)} />
|
|
||||||
<ConnectionCreateOption theme={theme} icon={<Group className="size-5" />} title="组" onClick={() => onCreate(CanvasNodeType.Group)} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -241,6 +261,8 @@ function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; o
|
|||||||
|
|
||||||
function InfiniteCanvasPage() {
|
function InfiniteCanvasPage() {
|
||||||
const { message, modal } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
|
// 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染
|
||||||
|
const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version);
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -322,6 +344,7 @@ function InfiniteCanvasPage() {
|
|||||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||||
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
||||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||||
|
const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
|
||||||
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
||||||
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
|
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
|
||||||
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
|
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
|
||||||
@@ -602,7 +625,7 @@ function InfiniteCanvasPage() {
|
|||||||
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
||||||
setSelectedNodeIds(new Set([newNode.id]));
|
setSelectedNodeIds(new Set([newNode.id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||||
setPendingConnectionCreate(null);
|
setPendingConnectionCreate(null);
|
||||||
setConnecting(null);
|
setConnecting(null);
|
||||||
},
|
},
|
||||||
@@ -791,8 +814,38 @@ function InfiniteCanvasPage() {
|
|||||||
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
|
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
|
||||||
return () => setAgentCanvasContext(null);
|
return () => setAgentCanvasContext(null);
|
||||||
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
|
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
|
||||||
|
|
||||||
|
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
|
||||||
|
const pluginHost = useMemo<CanvasPluginHost>(
|
||||||
|
() => ({
|
||||||
|
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
|
||||||
|
getNodes: () => nodesRef.current,
|
||||||
|
getConnections: () => connectionsRef.current,
|
||||||
|
getUpstream: (nodeId) => connectionsRef.current.filter((conn) => conn.toNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||||
|
getDownstream: (nodeId) => connectionsRef.current.filter((conn) => conn.fromNodeId === nodeId).map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||||
|
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
|
||||||
|
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
|
||||||
|
applyOps: (ops) => applyAgentOps(ops),
|
||||||
|
}),
|
||||||
|
[applyAgentOps],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPluginPanel = useCallback(
|
||||||
|
(panelNode: CanvasNodeData) => {
|
||||||
|
const Panel = getNodeDefinition(panelNode.type)?.Panel;
|
||||||
|
if (!Panel) return null;
|
||||||
|
const ctx = buildNodeContext(pluginHost, panelNode, theme, viewportRef.current.k);
|
||||||
|
return <Panel ctx={ctx} onClose={() => setDialogNodeId(null)} />;
|
||||||
|
},
|
||||||
|
[pluginHost, theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 启动时加载已安装的远程插件
|
||||||
|
useEffect(() => {
|
||||||
|
void ensurePluginsLoaded();
|
||||||
|
}, []);
|
||||||
const createNode = useCallback(
|
const createNode = useCallback(
|
||||||
(type: CanvasNodeType, position?: Position) => {
|
(type: CanvasNodeTypeId, position?: Position) => {
|
||||||
const targetPosition = position || getCanvasCenter();
|
const targetPosition = position || getCanvasCenter();
|
||||||
const configMetadata =
|
const configMetadata =
|
||||||
type === CanvasNodeType.Config
|
type === CanvasNodeType.Config
|
||||||
@@ -807,7 +860,8 @@ function InfiniteCanvasPage() {
|
|||||||
setNodes((prev) => [...prev, newNode]);
|
setNodes((prev) => [...prev, newNode]);
|
||||||
setSelectedNodeIds(new Set([newNode.id]));
|
setSelectedNodeIds(new Set([newNode.id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
const definition = getNodeDefinition(type);
|
||||||
|
if (definition?.Panel || (isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group)) setDialogNodeId(newNode.id);
|
||||||
},
|
},
|
||||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||||
);
|
);
|
||||||
@@ -996,6 +1050,21 @@ function InfiniteCanvasPage() {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}, [size.height, size.width]);
|
}, [size.height, size.width]);
|
||||||
|
|
||||||
|
const focusNode = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const worldX = node.position.x + node.width / 2;
|
||||||
|
const worldY = node.position.y + node.height / 2;
|
||||||
|
const k = Math.min(Math.max(Math.min((size.width * 0.6) / node.width, (size.height * 0.6) / node.height), 0.05), 1.5);
|
||||||
|
setViewport({ x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k });
|
||||||
|
setSelectedNodeIds(new Set([nodeId]));
|
||||||
|
setSelectedConnectionId(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
},
|
||||||
|
[size.height, size.width],
|
||||||
|
);
|
||||||
|
|
||||||
const setZoomScale = useCallback(
|
const setZoomScale = useCallback(
|
||||||
(scale: number) => {
|
(scale: number) => {
|
||||||
const nextScale = Math.min(Math.max(scale, 0.05), 5);
|
const nextScale = Math.min(Math.max(scale, 0.05), 5);
|
||||||
@@ -1598,13 +1667,13 @@ function InfiniteCanvasPage() {
|
|||||||
const content = node.metadata?.content?.trim();
|
const content = node.metadata?.content?.trim();
|
||||||
if (!content) return message.error("没有可保存的文本");
|
if (!content) return message.error("没有可保存的文本");
|
||||||
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
|
addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } });
|
||||||
message.success("已加入我的素材");
|
message.success("已加入我的资产");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.type === CanvasNodeType.Video) {
|
if (node.type === CanvasNodeType.Video) {
|
||||||
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
if (!node.metadata?.content) return message.error("没有可保存的视频");
|
||||||
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
|
addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt } });
|
||||||
message.success("已加入我的素材");
|
message.success("已加入我的资产");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!node.metadata?.content) return message.error("没有可保存的图片");
|
if (!node.metadata?.content) return message.error("没有可保存的图片");
|
||||||
@@ -1625,7 +1694,7 @@ function InfiniteCanvasPage() {
|
|||||||
},
|
},
|
||||||
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
|
metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt },
|
||||||
});
|
});
|
||||||
message.success("已加入我的素材");
|
message.success("已加入我的资产");
|
||||||
},
|
},
|
||||||
[addAsset, message],
|
[addAsset, message],
|
||||||
);
|
);
|
||||||
@@ -2502,6 +2571,7 @@ function InfiniteCanvasPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
<main className="flex h-full min-h-0 overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
||||||
|
<CanvasSidePanel nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={focusNode} onInsertAsset={handleAssetInsert} />
|
||||||
<section className="relative min-w-0 flex-1 overflow-hidden">
|
<section className="relative min-w-0 flex-1 overflow-hidden">
|
||||||
<CanvasTopBar
|
<CanvasTopBar
|
||||||
title={currentProject?.title || "未命名画布"}
|
title={currentProject?.title || "未命名画布"}
|
||||||
@@ -2518,6 +2588,7 @@ function InfiniteCanvasPage() {
|
|||||||
onCreateProject={createAndOpenProject}
|
onCreateProject={createAndOpenProject}
|
||||||
onDeleteProject={deleteCurrentProject}
|
onDeleteProject={deleteCurrentProject}
|
||||||
onImportImage={() => handleUploadRequest()}
|
onImportImage={() => handleUploadRequest()}
|
||||||
|
onOpenPlugins={() => setPluginManagerOpen(true)}
|
||||||
onUndo={undoCanvas}
|
onUndo={undoCanvas}
|
||||||
onRedo={redoCanvas}
|
onRedo={redoCanvas}
|
||||||
agentOpen={agentPanelOpen}
|
agentOpen={agentPanelOpen}
|
||||||
@@ -2600,8 +2671,12 @@ function InfiniteCanvasPage() {
|
|||||||
showImageInfo={showImageInfo}
|
showImageInfo={showImageInfo}
|
||||||
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
||||||
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
||||||
|
pluginHost={pluginHost}
|
||||||
|
registryVersion={nodeRegistryVersion}
|
||||||
renderPanel={(panelNode) =>
|
renderPanel={(panelNode) =>
|
||||||
panelNode.type === CanvasNodeType.Config ? (
|
getNodeDefinition(panelNode.type)?.Panel ? (
|
||||||
|
renderPluginPanel(panelNode)
|
||||||
|
) : panelNode.type === CanvasNodeType.Config ? (
|
||||||
<CanvasConfigComposer
|
<CanvasConfigComposer
|
||||||
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||||
inputs={configInputsById.get(panelNode.id) || []}
|
inputs={configInputsById.get(panelNode.id) || []}
|
||||||
@@ -2692,6 +2767,7 @@ function InfiniteCanvasPage() {
|
|||||||
<CanvasNodeHoverToolbar
|
<CanvasNodeHoverToolbar
|
||||||
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
|
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
|
||||||
viewport={viewport}
|
viewport={viewport}
|
||||||
|
extraTools={toolbarNode ? getNodeDefinition(toolbarNode.type)?.toolbar?.(buildNodeContext(pluginHost, toolbarNode, theme, viewport.k)) : undefined}
|
||||||
onKeep={keepNodeToolbar}
|
onKeep={keepNodeToolbar}
|
||||||
onLeave={hideNodeToolbar}
|
onLeave={hideNodeToolbar}
|
||||||
onInfo={(node) => setInfoNodeId(node.id)}
|
onInfo={(node) => setInfoNodeId(node.id)}
|
||||||
@@ -2728,6 +2804,7 @@ function InfiniteCanvasPage() {
|
|||||||
onAddText={() => createNode(CanvasNodeType.Text)}
|
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||||
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
||||||
onAddGroup={() => createNode(CanvasNodeType.Group)}
|
onAddGroup={() => createNode(CanvasNodeType.Group)}
|
||||||
|
onAddExtensionNode={(type) => createNode(type)}
|
||||||
onUndo={undoCanvas}
|
onUndo={undoCanvas}
|
||||||
onRedo={redoCanvas}
|
onRedo={redoCanvas}
|
||||||
onUpload={() => handleUploadRequest()}
|
onUpload={() => handleUploadRequest()}
|
||||||
@@ -2736,9 +2813,6 @@ function InfiniteCanvasPage() {
|
|||||||
onDeselect={deselectCanvas}
|
onDeselect={deselectCanvas}
|
||||||
onBackgroundModeChange={setBackgroundMode}
|
onBackgroundModeChange={setBackgroundMode}
|
||||||
onShowImageInfoChange={setShowImageInfo}
|
onShowImageInfoChange={setShowImageInfo}
|
||||||
onOpenMyAssets={() => {
|
|
||||||
setAssetPickerOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} onViewportChange={setViewport} /> : null}
|
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} onViewportChange={setViewport} /> : null}
|
||||||
@@ -2768,6 +2842,7 @@ function InfiniteCanvasPage() {
|
|||||||
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
|
<input ref={imageInputRef} type="file" accept="image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" className="hidden" onChange={handleImageInputChange} />
|
||||||
|
|
||||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
|
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} />
|
||||||
|
<CanvasPluginManagerModal open={pluginManagerOpen} onClose={() => setPluginManagerOpen(false)} />
|
||||||
|
|
||||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
||||||
|
|
||||||
@@ -2839,6 +2914,7 @@ function CanvasTopBar({
|
|||||||
onCreateProject,
|
onCreateProject,
|
||||||
onDeleteProject,
|
onDeleteProject,
|
||||||
onImportImage,
|
onImportImage,
|
||||||
|
onOpenPlugins,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
agentOpen,
|
agentOpen,
|
||||||
@@ -2859,6 +2935,7 @@ function CanvasTopBar({
|
|||||||
onCreateProject: () => void;
|
onCreateProject: () => void;
|
||||||
onDeleteProject: () => void;
|
onDeleteProject: () => void;
|
||||||
onImportImage: () => void;
|
onImportImage: () => void;
|
||||||
|
onOpenPlugins: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
agentOpen: boolean;
|
agentOpen: boolean;
|
||||||
@@ -2894,7 +2971,7 @@ function CanvasTopBar({
|
|||||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||||
{ type: "divider" },
|
{ type: "divider" },
|
||||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
||||||
{ type: "divider" },
|
{ type: "divider" },
|
||||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||||
@@ -2938,6 +3015,7 @@ function CanvasTopBar({
|
|||||||
<UserStatusActions
|
<UserStatusActions
|
||||||
variant="canvas"
|
variant="canvas"
|
||||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
onOpenPlugins={onOpenPlugins}
|
||||||
/>
|
/>
|
||||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export default function ImagePage() {
|
|||||||
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
|
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
|
||||||
metadata: { source: "image-page", prompt },
|
metadata: { source: "image-page", prompt },
|
||||||
});
|
});
|
||||||
message.success("已加入我的素材");
|
message.success("已加入我的资产");
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||||
@@ -242,7 +242,7 @@ export default function ImagePage() {
|
|||||||
const stored = await uploadImage(payload.dataUrl);
|
const stored = await uploadImage(payload.dataUrl);
|
||||||
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
||||||
} else {
|
} else {
|
||||||
message.warning("生图工作台只能使用文本或图片素材");
|
message.warning("生图工作台只能使用文本或图片资产");
|
||||||
}
|
}
|
||||||
setAssetPickerOpen(false);
|
setAssetPickerOpen(false);
|
||||||
};
|
};
|
||||||
@@ -388,7 +388,7 @@ export default function ImagePage() {
|
|||||||
查看提示词库
|
查看提示词库
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||||
查看我的素材
|
查看我的资产
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,9 +560,9 @@ function ResultImageCard({
|
|||||||
<span>{formatDuration(image.durationMs)}</span>
|
<span>{formatDuration(image.durationMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid min-w-0 grid-cols-3 gap-2">
|
<div className="grid min-w-0 grid-cols-3 gap-2">
|
||||||
<Tooltip title="添加到素材">
|
<Tooltip title="添加到资产">
|
||||||
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
<Button className={RESULT_ACTION_BUTTON_CLASS} size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>
|
||||||
添加到素材
|
添加到资产
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="加入参考图">
|
<Tooltip title="加入参考图">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
|||||||
</Button>
|
</Button>
|
||||||
{onSaveAsset ? (
|
{onSaveAsset ? (
|
||||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||||
加入我的素材
|
加入我的资产
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function PromptsPage() {
|
|||||||
|
|
||||||
const savePromptAsset = (item: Prompt) => {
|
const savePromptAsset = (item: Prompt) => {
|
||||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||||
message.success("已加入我的素材");
|
message.success("已加入我的资产");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||||
@@ -106,7 +106,7 @@ export default function PromptsPage() {
|
|||||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
||||||
extraAction={
|
extraAction={
|
||||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
||||||
加入我的素材
|
加入我的资产
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user