mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-26 16:54:29 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e1edd1484 | |||
| 5107be0acf | |||
| 219b3e84db | |||
| a3411cf9d9 | |||
| 430d1bd956 | |||
| cf0561d12a | |||
| e6dcd7a832 | |||
| 187c499ef9 | |||
| cf1ea2646f | |||
| 57db0fe752 | |||
| c5f53454aa | |||
| ca6efdf0c3 | |||
| a2586ffe34 | |||
| 5a27aae99e | |||
| 66961ce10f | |||
| 0c4288b832 | |||
| 093260f7b2 | |||
| 805573b980 | |||
| e6829333c5 | |||
| 2959b5d340 | |||
| 2f9776bee3 | |||
| 4beadc6161 | |||
| 13c508bc34 | |||
| 5f627d5baf | |||
| 3f179a607a | |||
| 2d2c7e3d5e | |||
| 3f623ea70c | |||
| 7ac2ab351d | |||
| b6e75c225e | |||
| 75aafc115f | |||
| 8d3f244524 | |||
| 88510223eb | |||
| eef4d96787 | |||
| f0db29d8e3 | |||
| 3963c6eebf | |||
| c57f7d61a7 |
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: bun install --frozen-lockfile
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
|
||||
@@ -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
|
||||
@@ -11,4 +11,7 @@ data/
|
||||
web/dist
|
||||
|
||||
**/dist
|
||||
web/public/plugins
|
||||
plugins/canvas/*/dist
|
||||
plugins/canvas/*/node_modules
|
||||
web/public/plugins
|
||||
@@ -51,6 +51,8 @@
|
||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
||||
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
|
||||
- 画布内的操作按钮(如面板里的「添加」「导出」「选择」等)默认用扁平无底色样式:透明背景、仅 `hover:bg-black/5 dark:hover:bg-white/10` 轻微反馈,靠图标+文字表达,不要用 `theme.toolbar.activeBg`(`#e7e5df`/`#3a3631`)或 `theme.node.fill` 之类的灰色作为按钮填充底色。灰色 `activeBg` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
|
||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||
|
||||
|
||||
+41
-3
@@ -2,15 +2,54 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.9.0 - 2026-07-17
|
||||
|
||||
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
|
||||
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
|
||||
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
|
||||
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
|
||||
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
|
||||
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
|
||||
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
|
||||
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
|
||||
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
|
||||
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
|
||||
|
||||
## v0.8.2 - 2026-07-16
|
||||
|
||||
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||
+ [修复] 画布节点提示词输入框补上悬停文本光标。
|
||||
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
|
||||
|
||||
## v0.8.1 - 2026-07-16
|
||||
|
||||
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
|
||||
+ [优化] 3D 全景(1.1.0)支持上传与 AI 生成并升级查看器;
|
||||
+ [优化] HTML 节点(1.2.0)迁移到统一交互开关;
|
||||
+ [优化] 便利贴(1.1.0)可拖动移动、自选颜色、移除资源角标与衍生功能;
|
||||
+ [优化] SVG 节点(1.1.0)透明背景融入画布、可拖动、去除默认值;
|
||||
+ [优化] 画布节点渲染性能:memo 化节点回调,交互时不再全量重渲染;
|
||||
+ [修复] 修复 Markdown 节点在点击/移动视角时重复渲染导致图片反复请求的问题;
|
||||
+ [修复] 修复插件版本号显示不更新,插件面板新增可升级绿点提醒。
|
||||
|
||||
## v0.8.0 - 2026-07-15
|
||||
|
||||
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
||||
+ [新增] 插件开发 SDK,可用 TypeScript 开发画布节点插件。
|
||||
+ [新增] 新增 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
+ [新增] 官方插件注册表:节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
+ [新增] 在画布右上角工具栏新增「节点插件」入口。
|
||||
+ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。
|
||||
|
||||
## v0.7.1 - 2026-07-15
|
||||
|
||||
+ [修复] 修复通过 `crypto.randomUUID` 不可用导致页面白屏报错的问题,改用 nanoid 生成 id。
|
||||
+ [修复] 修复页面白屏报错的问题。
|
||||
|
||||
## v0.7.0 - 2026-07-14
|
||||
|
||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
|
||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||
@@ -18,7 +57,6 @@
|
||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||
+ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。
|
||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
||||
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
||||
|
||||
@@ -14,5 +14,7 @@ FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=web-build /app/web/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
## 核心功能
|
||||
|
||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
||||
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
||||
- 插件系统:支持通过 URL 动态安装 / 启用 / 更新 / 卸载远程节点插件,并提供 TypeScript SDK 自行开发画布节点插件。
|
||||
- 自定义接口调用:可自定义生图 / 视频接口的调用方式,灵活适配各类中转站与自建服务。
|
||||
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
|
||||
|
||||
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
|
||||
@@ -68,16 +70,7 @@ docker compose up -d
|
||||
|
||||
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
||||
|
||||
## New API 自动配置
|
||||
|
||||
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
|
||||
|
||||
```text
|
||||
https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
```
|
||||
|
||||
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
|
||||
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
|
||||
如果默认的OpenAI接口调用方式与您的API不同,可自定义生图/视频脚本调用。
|
||||
|
||||
## 效果展示
|
||||
|
||||
@@ -108,20 +101,9 @@ https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
|
||||
## 赞助支持
|
||||
|
||||
<div align="center">
|
||||
本项目长期开放广告赞助合作,欢迎品牌 / 产品投放,你的支持是持续更新的动力!
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力!
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://ifdian.net/a/basketikun">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
有广告赞助意向请通过上方联系方式沟通。
|
||||
|
||||
## 社区支持
|
||||
|
||||
|
||||
+16
@@ -31,6 +31,22 @@ Please include:
|
||||
|
||||
## 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:
|
||||
|
||||
- Cross-site scripting or token exfiltration in the web app.
|
||||
|
||||
@@ -5,3 +5,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
|
||||
# environment:
|
||||
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
|
||||
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
|
||||
|
||||
@@ -24,6 +24,7 @@ description: 当前画布节点的主要用途与操作流程
|
||||
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
||||
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
||||
- 输入内容可以手写,也可以从提示词库选择。
|
||||
- 输入框支持 `@` 引用已连接的图片、文本、视频、音频资源;`@` 引用图片时会在输入框内直接显示真实缩略图,发送时按当前连接自动编号交给模型。因此不需要再记忆「图片1 / 文本1」编号,画布节点右上角也不再显示资源角标。
|
||||
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
||||
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"canvas-data-structure"
|
||||
"canvas-data-structure",
|
||||
"local-codex-canvas"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,3 +50,42 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
## 数据说明
|
||||
|
||||
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||
|
||||
## 统计分析(可选)
|
||||
|
||||
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
|
||||
|
||||
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID(`G-XXXX`) |
|
||||
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID(`hm.js?` 后面那串 hash) |
|
||||
|
||||
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
ANALYTICS_GA4_ID: G-XXXXXXXXXX
|
||||
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
|
||||
```
|
||||
|
||||
或使用 `docker run`:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
|
||||
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
|
||||
ghcr.io/basketikun/infinite-canvas:latest
|
||||
```
|
||||
|
||||
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
|
||||
|
||||
<Callout type="warn">
|
||||
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
|
||||
</Callout>
|
||||
|
||||
@@ -85,9 +85,12 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 生成数量。
|
||||
- 视频模型。
|
||||
- 视频比例、清晰度、时长、生成声音和水印。
|
||||
- 自定义生图 / 视频接口的调用方式,灵活适配不同中转站与自建服务。
|
||||
|
||||
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
||||
|
||||
节点下方对话框和组装提示词输入框都支持 `@` 引用已连接的图片、文本、视频、音频等资源;`@` 引用图片时,输入框内会直接显示该图片的真实缩略图,而不再是「图片1」这类文字编号,发送时会按当前连接自动编号交给模型理解。由于引用改在对话框内直接 `@`,画布节点右上角不再显示「图片1 / 文本1」资源角标。
|
||||
|
||||
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
||||
|
||||
## 画布助手
|
||||
@@ -106,6 +109,18 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 把助手生成的图片插入画布。
|
||||
- 折叠和展开助手面板。
|
||||
|
||||
## 节点插件系统
|
||||
|
||||
画布支持通过节点插件扩展新的节点类型,内置节点之外的能力都可以以插件形式加载:
|
||||
|
||||
- 支持通过 URL 动态安装、启用、更新和卸载远程节点插件。
|
||||
- 提供官方插件注册表,节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
- 提供 TypeScript 插件开发 SDK,可自行开发画布节点插件,并使用 AI 生成、面板控制等扩展能力。
|
||||
- 内置 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
- 插件面板会显示插件版本,并对可升级的插件给出提醒。
|
||||
|
||||
Codex App 插件的安装与使用见 [Codex App 插件](/docs/overview/codex-app-plugin)。
|
||||
|
||||
## 提示词库
|
||||
|
||||
前台提示词库支持:
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"render",
|
||||
"docker",
|
||||
"third-party-prompt-repositories",
|
||||
"[在线体验](https://canvas.best/)",
|
||||
"local-codex-canvas"
|
||||
"[在线体验](https://canvas.best/)"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"pages": [
|
||||
"[更新日志](/docs/progress/changelog)",
|
||||
"local-agent-integration-plan",
|
||||
"prompt-chip-input-plan",
|
||||
"pending-test",
|
||||
"todo"
|
||||
]
|
||||
|
||||
@@ -5,7 +5,18 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
|
||||
- 左侧画布面板「资产」Tab:搜索框旁「添加」按钮可上传图片/视频文件(支持多选)加入资产库;每张资产卡片悬停显示移除按钮,二次确认后删除;点击卡片仍插入到画布;需验证添加图片/视频、非图片视频文件的提示、移除确认与插入行为。
|
||||
- 左侧画布面板:支持拖拽右边缘调整宽度(记忆到本地)、通过顶栏菜单左侧的开关按钮展开/收起,展开收起有动画;需验证宽度拖拽、动画、刷新后保持展开/收起状态与宽度。
|
||||
- 左侧画布面板:「画布/资产」切换改为带滑动下划线的动画;点击「画布元素」列表项跳转到对应节点时带缓动动画(视图平滑移动缩放而非瞬移);文本/配置/视频/音频等非图片元素的图标去掉灰色底色;需验证视觉与交互。
|
||||
- 左侧画布面板:画布元素列表点击「选择」进入多选模式,可勾选/全选节点后「导出选中」为压缩包;压缩包为扁平结构——图片/视频/音频直接是对应文件(按节点名称命名、重名自动加序号),文本节点为 `.txt`,其余无法识别的节点各自导出为同名 `.json`;需验证多选、全选、导出内容与命名正确。
|
||||
- 顶栏菜单:新增「导出当前画布」,导出当前画布为压缩包(与项目列表页单个导出格式一致);需验证导出文件名与内容。
|
||||
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件。
|
||||
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 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 Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
|
||||
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 提示词面板引用显示图片缩略图规划
|
||||
description: 将提示词面板输入框从 textarea 改造为 contentEditable,让 @ 引用直接显示真实图片缩略图
|
||||
---
|
||||
|
||||
# 提示词面板 @ 引用显示真实图片缩略图
|
||||
|
||||
## 背景 / 要解决的问题
|
||||
|
||||
提示词面板(`CanvasNodePromptPanel`)的输入框里,`@` 引用一张图片后,显示的是蓝色文字 chip「图片1」。期望**直接显示这张图片的真实缩略图**,而不是文字编号。
|
||||
|
||||
当前该输入框是原生 `<textarea>`,只能承载纯文本,无法内嵌图片。要显示缩略图,必须改用 `contentEditable`(可编辑 div),把引用做成带 `<img>` 的原子小卡片。项目里「组装提示词」组件(`CanvasConfigComposer`)已经是这套 contentEditable + 图片 chip 的成熟实现,可作蓝本。
|
||||
|
||||
## 关键约束(为什么不能直接改现有组件)
|
||||
|
||||
`CanvasResourceMentionTextarea` 被两处复用:
|
||||
|
||||
1. **提示词面板** `web/src/components/canvas/canvas-node-prompt-panel.tsx` —— 本次要改的对象。`highlightLabels` 默认 true,value 是提示词字符串,引用以 label 文本「图片1」嵌在其中,**直接发给 AI**(配合 `web/src/lib/image-reference-prompt.ts` 的「参考图片编号:图片1」前缀)。
|
||||
2. **节点内文本编辑** `web/src/components/canvas/canvas-node.tsx`(`isEditingContent` 分支)—— `highlightLabels={false}`,通过 `ref` 调用 `textarea.setSelectionRange(...)` 等 **textarea 专有 API**。若把组件改成 contentEditable,这处会直接崩。
|
||||
|
||||
> 结论:**新建一个 contentEditable chip 输入组件,只替换提示词面板那一处**;`CanvasResourceMentionTextarea`(textarea 版)保持不动,继续服务节点内文本编辑。
|
||||
|
||||
## 方案:新增 `CanvasPromptChipInput` 组件
|
||||
|
||||
新文件 `web/src/components/canvas/canvas-prompt-chip-input.tsx`,参照 `canvas-config-composer.tsx` 的 contentEditable 模式,但有一处**核心差异**:
|
||||
|
||||
- 「组装提示词」把引用序列化成 `@[node:UUID]` 标记;
|
||||
- 本组件的 value 必须保持**引用的 label 文本**(如「图片1」),这样发给生成的提示词语义不变。因此 chip ↔ 文本的序列化用 **label 文本**,不是 node 标记。
|
||||
|
||||
### 组件契约(与现用法一致)
|
||||
|
||||
Props:`value: string`、`references: CanvasResourceReference[]`、`onChange(value)`、`onSubmit?()`、`className`、`style`、`placeholder`。图片缩略图取 `reference.previewUrl`(即 `node.metadata.content`,已存在于 `CanvasResourceReference`)。
|
||||
|
||||
### 实现要点(大量复用 composer 的做法)
|
||||
|
||||
1. **DOM ← value(未聚焦时重建)**:仿 `CanvasConfigComposer` 的重建 effect。用 active label 列表(`references.filter(active).map(label)`,按长度降序)把 value 字符串切成「文本片段 + 命中 label」,命中处插入 chip。聚焦时不重建(`document.activeElement === editor` 直接 return),避免与输入 / IME 打架。
|
||||
2. **chip 渲染**:仿 `createReferenceChip`。image 类型 → `size-6` 的 `<img src={reference.previewUrl}>`;其它类型 → 截断文字。chip 为 `contentEditable="false"` 的 `<span>`,用 `dataset.refLabel` 存 label 以便序列化。
|
||||
3. **value ← DOM(syncFromEditor)**:仿 `serializeNodes`,但 chip 序列化为 `dataset.refLabel`(label 文本)而非 node 标记;文本节点原样拼接,`<br>` → `\n`。
|
||||
4. **@ 菜单**:复用 `textBeforeCaret` + `/@([^\s@]*)$/` 检测;菜单定位改用 `getSelection().getRangeAt(0).getBoundingClientRect()`(比 textarea 的镜像 div 更简单、天然贴着光标)。菜单 UI 沿用现有 `MentionMenu` 的视觉(缩略图 + label + 副标题)。
|
||||
5. **原子删除**:直接复用 composer 的 `deleteAdjacentReference` / `adjacentReferenceNode` / `findReferenceSibling`——contentEditable + `contentEditable="false"` chip 天然整体删除。可把这几个通用 DOM 助手连同 `textBeforeCaret` / `placeCaretAtEnd` / `closestEditor` 复制进新组件(保持 composer 不动,规避回归风险)。
|
||||
6. **回车发送 + IME**:复用 `isPlainEnterKey` / `isImeComposing`(`@/lib/keyboard-event`)与 `onCompositionStart/End`,发送时调用 `onSubmit`。
|
||||
7. **占位符 & 样式**:空值时显示 placeholder(absolute 占位层);容器套用面板传入的 `className/style`(`min-h-40`、透明底、无边框,与当前一致)。
|
||||
|
||||
### 替换点
|
||||
|
||||
`canvas-node-prompt-panel.tsx` 里把 `<CanvasResourceMentionTextarea .../>` 换成 `<CanvasPromptChipInput .../>`(同样的 value/references/onChange/onSubmit/className/style/placeholder)。其余(模型选择、设置、生成按钮)不动。
|
||||
|
||||
### 不改动
|
||||
|
||||
- `CanvasResourceMentionTextarea`(textarea 版)保留,节点内文本编辑继续用它。
|
||||
- 生成链路、`image-reference-prompt.ts` 前缀逻辑不变(value 仍是 label 文本)。
|
||||
|
||||
## 影响文件
|
||||
|
||||
- 新增:`web/src/components/canvas/canvas-prompt-chip-input.tsx`
|
||||
- 修改:`web/src/components/canvas/canvas-node-prompt-panel.tsx`(仅替换输入框组件 + 相应 import)
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npx tsc --noEmit` 通过。
|
||||
2. dev server 刷新后:
|
||||
- 连一张图片到图片 / 视频节点 → 面板输入框输入 `@` → 菜单贴着光标弹出 → 选中 → **输入框内显示该图的真实缩略图 chip**(不再是「图片1」文字)。
|
||||
- 光标在 chip 后按一次 Backspace → 整个 chip 一次删除。
|
||||
- 普通文字 + chip 混排,回车触发生成;确认发给生成的 prompt 里引用仍是「图片1」文本(生成结果正常引用到图)。
|
||||
- 中文输入法输入不吞字、不误触发发送。
|
||||
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
title: 打赏支持
|
||||
description: 支持无限画布项目继续维护
|
||||
---
|
||||
|
||||
# 打赏支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
|
||||
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
|
||||
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -4,7 +4,6 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"security",
|
||||
"donate",
|
||||
"sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
---
|
||||
title: 广告赞助
|
||||
description: 广告赞助、项目露出和社区推广说明
|
||||
title: 赞助支持
|
||||
description: 赞助支持、广告合作、项目露出和社区推广说明
|
||||
---
|
||||
|
||||
# 广告赞助
|
||||
# 赞助支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过 Star、广告赞助合作、提供 AI 订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。相关支持会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 通过广告赞助合作支持项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
## 广告赞助
|
||||
|
||||
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
||||
|
||||
## 适合的赞助内容
|
||||
### 适合的赞助内容
|
||||
|
||||
- AI 工具、模型服务、API 服务或开发者工具。
|
||||
- 与图片、视频、文本生成工作流相关的产品。
|
||||
- 适合开源项目用户和 AI 创作者使用的服务。
|
||||
|
||||
## 建议提供的信息
|
||||
### 建议提供的信息
|
||||
|
||||
- 品牌或产品名称。
|
||||
- 希望展示的位置和周期。
|
||||
|
||||
+2
-3
@@ -27,8 +27,7 @@
|
||||
## 支持与安全
|
||||
|
||||
- [漏洞提交](/docs/support/security)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
- [赞助支持](/docs/support/sponsor)
|
||||
|
||||
## 项目进度
|
||||
|
||||
@@ -43,4 +42,4 @@
|
||||
|
||||
## 原理说明
|
||||
|
||||
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
|
||||
- [本地 Codex 连接画布原理](/docs/development/local-codex-canvas)
|
||||
|
||||
@@ -10,7 +10,7 @@ const tabs = [
|
||||
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
|
||||
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
||||
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
||||
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
|
||||
{ title: '赞助支持', href: '/docs/support/sponsor', prefix: '/docs/support' },
|
||||
];
|
||||
|
||||
export function DocsTopTabs() {
|
||||
|
||||
@@ -7,4 +7,9 @@ server {
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.2.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,138 @@
|
||||
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
||||
// 交互:预览态 iframe 默认 pointer-events:none —— 鼠标事件穿透到宿主节点体,
|
||||
// 因此点节点任意位置都能拖动,无需在节点上加任何标题栏/按钮。
|
||||
// 「编辑/预览」与「交互」开关都放在节点外的悬浮工具条(toolbar 扩展点),状态存 metadata。
|
||||
import { definePlugin, useMemo, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
|
||||
const EDITOR_FONT = 12;
|
||||
const EDITOR_LINE = 20;
|
||||
|
||||
function HtmlEditor({ ctx, value }: { ctx: CanvasNodeContentProps["ctx"]; value: string }) {
|
||||
const gutterRef = useRef<HTMLDivElement>(null);
|
||||
// 行数:按换行统计,至少 1 行;value 变化时重算
|
||||
const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
const codeStyle = { fontFamily: "monospace", fontSize: EDITOR_FONT, lineHeight: `${EDITOR_LINE}px`, boxSizing: "border-box" } as const;
|
||||
|
||||
return (
|
||||
<div data-canvas-no-zoom style={{ height: "100%", width: "100%", display: "flex", overflow: "hidden", borderRadius: 16, background: ctx.theme.node.fill }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
{/* 行号槽:跟随文本域滚动,不可单独滚动 */}
|
||||
<div
|
||||
ref={gutterRef}
|
||||
aria-hidden
|
||||
style={{
|
||||
...codeStyle,
|
||||
flex: "0 0 auto",
|
||||
padding: "16px 8px 16px 12px",
|
||||
textAlign: "right",
|
||||
color: ctx.theme.node.placeholder,
|
||||
background: `${ctx.theme.toolbar.panel}66`,
|
||||
borderRight: `1px solid ${ctx.theme.node.stroke}`,
|
||||
overflow: "hidden",
|
||||
userSelect: "none",
|
||||
whiteSpace: "pre",
|
||||
}}
|
||||
>
|
||||
{/* 用负 margin 让整列跟随 scrollTop 平移,和 textarea 同步 */}
|
||||
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="<div>Hello, {{input}}</div>"
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ ...codeStyle, flex: "1 1 auto", minWidth: 0, height: "100%", resize: "none", background: "transparent", padding: "16px 16px 16px 12px", outline: "none", border: "none", color: ctx.theme.node.text, whiteSpace: "pre", overflow: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||
const value = ctx.node.metadata?.content || "";
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
const upstreamText = useMemo(
|
||||
() =>
|
||||
ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
[ctx],
|
||||
);
|
||||
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
|
||||
|
||||
if (editing) {
|
||||
return <HtmlEditor ctx={ctx} value={value} />;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
|
||||
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
||||
<span style={{ fontSize: 13 }}>选中节点,点上方工具条的 ✎ 编辑 HTML</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 预览态:iframe 的鼠标交互由宿主「交互 ⇄ 移动」开关统一控制(见 interactionToggle),
|
||||
// 这里无需再手动做 pointer-events 穿透。data-canvas-no-zoom 保证交互时滚动作用于页面而非缩放画布。
|
||||
return (
|
||||
<div data-canvas-no-zoom style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||
<iframe
|
||||
title="html-preview"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
srcDoc={html}
|
||||
style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "html",
|
||||
name: "HTML 节点",
|
||||
version: "1.2.0",
|
||||
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||
nodes: [
|
||||
{
|
||||
type: "html:render",
|
||||
title: "HTML",
|
||||
icon: "🌐",
|
||||
description: "沙箱渲染 HTML",
|
||||
defaultSize: { width: 420, height: 320 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#ec4899",
|
||||
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互(编辑器始终可操作)并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
Content: HtmlContent,
|
||||
// 仅保留「编辑/预览」开关;交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "html-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 HTML 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.1.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,125 @@
|
||||
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
|
||||
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
|
||||
// 切「交互」态可滚动/选择;编辑态强制可交互。
|
||||
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
|
||||
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
|
||||
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
|
||||
import { definePlugin, useEffect, useRef, 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;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = "*选中节点,点上方工具条的 ✎ 编辑 Markdown*";
|
||||
|
||||
// 解析结果按源码缓存(模块级):同一段 Markdown 只解析一次,重复渲染/重挂载都命中缓存,
|
||||
// 返回的是同一个字符串引用,配合下方「值不变不写 DOM」彻底避免图片被重新请求。
|
||||
const htmlCache = new Map<string, string>();
|
||||
function renderMarkdown(source: string): string {
|
||||
if (!marked) return "";
|
||||
const key = source || PLACEHOLDER;
|
||||
let out = htmlCache.get(key);
|
||||
if (out === undefined) {
|
||||
out = marked.parse(key);
|
||||
htmlCache.set(key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 预览态:用 ref 手动写 innerHTML,且仅在 html 真正变化时写入。
|
||||
// 这样宿主的任何重渲染(点击、移动视角、选中态变化等)都不会触碰已渲染的 DOM,图片不会重新加载。
|
||||
function MarkdownPreview({ ctx }: CanvasNodeContentProps) {
|
||||
const [, force] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const lastHtml = useRef<string | null>(null);
|
||||
|
||||
// marked 未就绪时按需加载,加载完触发一次重渲染填充内容
|
||||
useEffect(() => {
|
||||
if (marked) return;
|
||||
let alive = true;
|
||||
loadMarked().then(() => alive && force((n) => n + 1));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const source = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
const html = renderMarkdown(source);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || lastHtml.current === html) return; // 内容没变:不动 DOM,已加载的图片保持原样
|
||||
el.innerHTML = html;
|
||||
lastHtml.current = html;
|
||||
}, [html]);
|
||||
|
||||
return <div ref={ref} className="cnv-md" data-canvas-no-zoom onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", color: ctx.theme.node.text }} />;
|
||||
}
|
||||
|
||||
function MarkdownEditor({ ctx }: CanvasNodeContentProps) {
|
||||
const value = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
return (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="# 输入 Markdown"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, boxSizing: "border-box", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
|
||||
return ctx.node.metadata?.editing ? <MarkdownEditor ctx={ctx} /> : <MarkdownPreview ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "markdown",
|
||||
name: "Markdown 节点",
|
||||
version: "1.1.0",
|
||||
description: "在画布中编辑与渲染 Markdown",
|
||||
css,
|
||||
nodes: [
|
||||
{
|
||||
type: "markdown:doc",
|
||||
title: "Markdown",
|
||||
icon: "📝",
|
||||
description: "编辑与渲染 Markdown",
|
||||
defaultSize: { width: 360, height: 300 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#6366f1",
|
||||
hidePanel: true, // 纯展示/编辑节点:不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||
Content: MarkdownContent,
|
||||
// 仅保留「编辑/预览」开关(状态存 metadata.editing);交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "md-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 Markdown 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.1.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,311 @@
|
||||
// 3D 全景节点:等距柱状(equirectangular)360° 全景查看器。
|
||||
// 三种取图方式:① 从上游图片节点自动取图 ② 本地上传 ③ AI 生成(复用宿主内置生成面板)。
|
||||
// three.js 从 CDN 按需加载,不打进 bundle。AI 生成通过 useBuiltinPanel 声明,结果写回本节点。
|
||||
import { definePlugin, useCallback, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps, CanvasNodeContext } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
|
||||
let threePromise: Promise<any> | undefined;
|
||||
function loadThree(): Promise<any> {
|
||||
if (!threePromise) threePromise = import("https://esm.sh/three@0.180.0");
|
||||
return threePromise;
|
||||
}
|
||||
|
||||
// AI 生成的固定前置提示词:约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||
// 用户只需描述场景内容,这段会自动拼在前面,保证 2:1、无缝、无畸变文字等。
|
||||
const PANORAMA_SYSTEM_PROMPT =
|
||||
"A seamless 360-degree equirectangular panorama, 2:1 aspect ratio, full spherical VR photo, " +
|
||||
"horizontally wrapping seamlessly at the left and right edges, no visible seam, no distortion artifacts, " +
|
||||
"even horizon, no text, no watermark. Scene: ";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全景查看器:球体内壁贴等距柱状纹理,指针拖动转视角、滚轮缩放 fov。
|
||||
// 自适应容器尺寸(ResizeObserver);超过 GPU 上限的大图先降采样,避免加载失败。
|
||||
// ---------------------------------------------------------------------------
|
||||
function PanoramaViewer({ src, ctx }: { src: string; ctx: CanvasNodeContext }) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount || !src) return;
|
||||
let disposed = false;
|
||||
let frame = 0;
|
||||
let renderer: any = null;
|
||||
let geometry: any = null;
|
||||
let material: any = null;
|
||||
let texture: any = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let cleanupEvents = () => {};
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
loadThree()
|
||||
.then((THREE: any) => {
|
||||
if (disposed || !mountRef.current) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
|
||||
const target = new THREE.Vector3();
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
const canvas: HTMLCanvasElement = renderer.domElement;
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
canvas.style.display = "block";
|
||||
canvas.style.cursor = "grab";
|
||||
mount.appendChild(canvas);
|
||||
const maxTextureSide = Math.min(renderer.capabilities.maxTextureSize || 4096, 4096);
|
||||
|
||||
geometry = new THREE.SphereGeometry(500, 96, 64);
|
||||
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
|
||||
material = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
scene.add(new THREE.Mesh(geometry, material));
|
||||
|
||||
// 视角状态
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
let fov = 70;
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startLon = 0;
|
||||
let startLat = 0;
|
||||
|
||||
const onDown = (event: PointerEvent) => {
|
||||
dragging = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
startLon = lon;
|
||||
startLat = lat;
|
||||
canvas.style.cursor = "grabbing";
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
lon = startLon - (event.clientX - startX) * 0.12;
|
||||
lat = Math.max(-84, Math.min(84, startLat + (event.clientY - startY) * 0.12));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragging = false;
|
||||
canvas.style.cursor = "grab";
|
||||
};
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
fov = Math.max(38, Math.min(86, fov + event.deltaY * 0.035));
|
||||
};
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
cleanupEvents = () => {
|
||||
canvas.removeEventListener("pointerdown", onDown);
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
canvas.removeEventListener("wheel", onWheel);
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
if (!renderer || !mountRef.current) return;
|
||||
const width = Math.max(1, mountRef.current.clientWidth);
|
||||
const height = Math.max(1, mountRef.current.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(mount);
|
||||
resize();
|
||||
|
||||
const render = () => {
|
||||
if (disposed || !renderer) return;
|
||||
if (!dragging) lon += 0.02; // 缓慢自转
|
||||
camera.fov = fov;
|
||||
camera.updateProjectionMatrix();
|
||||
const phi = THREE.MathUtils.degToRad(90 - lat);
|
||||
const theta = THREE.MathUtils.degToRad(lon);
|
||||
target.set(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
||||
camera.lookAt(target);
|
||||
renderer.render(scene, camera);
|
||||
frame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
|
||||
// 载入纹理:大图降采样到 GPU 上限,避免超限导致黑屏/失败
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
if (disposed || !material) return;
|
||||
try {
|
||||
const w = image.naturalWidth || image.width;
|
||||
const h = image.naturalHeight || image.height;
|
||||
const scale = Math.min(1, maxTextureSide / w, maxTextureSide / h);
|
||||
let source: HTMLImageElement | HTMLCanvasElement = image;
|
||||
if (scale < 1) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.max(1, Math.round(w * scale));
|
||||
c.height = Math.max(1, Math.round(h * scale));
|
||||
c.getContext("2d")?.drawImage(image, 0, 0, c.width, c.height);
|
||||
source = c;
|
||||
}
|
||||
texture = source instanceof HTMLCanvasElement ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
image.onerror = () => !disposed && setStatus("error");
|
||||
image.src = src;
|
||||
})
|
||||
.catch(() => !disposed && setStatus("error"));
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanupEvents();
|
||||
resizeObserver?.disconnect();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
texture?.dispose?.();
|
||||
material?.dispose?.();
|
||||
geometry?.dispose?.();
|
||||
if (renderer) {
|
||||
renderer.domElement?.remove?.();
|
||||
renderer.dispose?.();
|
||||
renderer.forceContextLoss?.();
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }}>
|
||||
<div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} />
|
||||
{status !== "ready" ? (
|
||||
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", color: ctx.theme.node.placeholder, fontSize: 13, background: status === "error" ? "rgba(0,0,0,0.6)" : `linear-gradient(135deg, ${ctx.theme.node.fill}, #000)` }}>
|
||||
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。
|
||||
// 整块不可点,避免误触;拖动节点照常。
|
||||
// ---------------------------------------------------------------------------
|
||||
function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 本地上传:读为 dataURL 写入自身 metadata.content
|
||||
const onPick = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => ctx.updateMetadata({ content: String(reader.result || "") });
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[ctx],
|
||||
);
|
||||
|
||||
const t = ctx.theme;
|
||||
const baseBtn = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
cursor: "pointer",
|
||||
borderRadius: 999,
|
||||
padding: "9px 18px",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${t.node.stroke}`,
|
||||
background: t.node.fill,
|
||||
color: t.node.text,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 20, borderRadius: 16, boxSizing: "border-box", background: t.node.fill }}>
|
||||
<span style={{ fontSize: 34 }}>🌐</span>
|
||||
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => onPick(e.target.files?.[0])} />
|
||||
<button type="button" style={baseBtn} onClick={() => fileRef.current?.click()}>
|
||||
🖼 上传全景图
|
||||
</button>
|
||||
<button type="button" style={{ ...baseBtn, border: "none", background: t.toolbar.activeBg, color: t.toolbar.activeText }} onClick={() => ctx.openPanel()}>
|
||||
✨ AI 生成
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: t.node.placeholder, textAlign: "center" }}>支持 2:1 等距柱状全景图</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
|
||||
const upstreamImage = ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.find((content): content is string => typeof content === "string" && Boolean(content));
|
||||
const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
|
||||
|
||||
if (!source) return <PanoramaEmpty ctx={ctx} />;
|
||||
return <PanoramaViewer src={source} ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "panorama",
|
||||
name: "3D 全景节点",
|
||||
version: "1.1.0",
|
||||
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
|
||||
nodes: [
|
||||
{
|
||||
type: "panorama:viewer",
|
||||
title: "3D 全景",
|
||||
icon: "🌐",
|
||||
description: "360° 全景查看器(上传 / AI 生成)",
|
||||
defaultSize: { width: 480, height: 300 },
|
||||
defaultMetadata: {},
|
||||
minimapColor: "#0ea5e9",
|
||||
// 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
|
||||
interactionToggle: true,
|
||||
// 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
|
||||
// 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||
useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
|
||||
// 作为上游被消费时,输出自身全景图,供下游节点引用
|
||||
resource: (node) => {
|
||||
const content = node.metadata?.content;
|
||||
return typeof content === "string" && content ? { kind: "image", url: content } : null;
|
||||
},
|
||||
Content: PanoramaContent,
|
||||
// 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
|
||||
toolbar: (ctx) => {
|
||||
const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
|
||||
return [
|
||||
{
|
||||
id: "panorama-generate",
|
||||
title: "用 AI 生成全景图(打开下方面板)",
|
||||
label: "AI 生成",
|
||||
icon: "✨",
|
||||
onClick: () => ctx.openPanel(),
|
||||
},
|
||||
...(hasOwnContent
|
||||
? [
|
||||
{
|
||||
id: "panorama-reset",
|
||||
title: "清空当前全景图,重新上传",
|
||||
label: "换图",
|
||||
icon: "🔄",
|
||||
onClick: () => ctx.updateMetadata({ content: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,69 @@
|
||||
// 官方插件集中构建:一次进程构建所有官方插件 + 生成清单,产物进 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, readFile, 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");
|
||||
|
||||
// 官方插件登记表:新增官方插件在此登记即可。
|
||||
// 版本不在这里写死 —— 从各插件的 package.json 读取(单一真源),
|
||||
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
|
||||
const OFFICIAL = [
|
||||
{ id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
|
||||
{ id: "svg", dir: "svg", name: "SVG 节点", description: "透明背景渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||
{ id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
|
||||
{ id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
|
||||
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", description: "可自选颜色、双击编辑、拖动即可移动的便利贴", icon: "📌" },
|
||||
];
|
||||
|
||||
// 读取插件 package.json 的 version 作为清单版本的唯一来源
|
||||
async function readPluginVersion(dir) {
|
||||
const pkg = JSON.parse(await readFile(join(root, "..", dir, "package.json"), "utf8"));
|
||||
if (!pkg.version) throw new Error(`插件 ${dir} 的 package.json 缺少 version`);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
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: await Promise.all(
|
||||
OFFICIAL.map(async (plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: await readPluginVersion(plugin.dir),
|
||||
description: plugin.description,
|
||||
icon: plugin.icon,
|
||||
entry: `${plugin.id}.js`,
|
||||
})),
|
||||
),
|
||||
};
|
||||
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
|
||||
console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`);
|
||||
+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,331 @@
|
||||
// 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 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 生成:插件直接复用宿主的模型/密钥配置发起生成(生图/生视频/生文本/生音频)
|
||||
//
|
||||
// 插件本身拿不到 API Key 与模型配置,这些能力由宿主注入。前置/系统提示词由
|
||||
// 插件自行拼进 prompt(宿主不感知),因此不同插件可各自定制自己的提示词策略。
|
||||
// 若宿主 AI 配置未就绪,会抛错(并由宿主提示用户去配置),插件用 try/catch 处理即可。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 生成公共可选项;references 为图生图/图生视频的参考图(dataURL 或可访问 URL)
|
||||
export type GenerateOptions = {
|
||||
signal?: AbortSignal;
|
||||
references?: string[];
|
||||
model?: string; // 指定模型(取自 ai.listModels 的 value);缺省用宿主当前配置
|
||||
};
|
||||
|
||||
export type GenerateImageOptions = GenerateOptions & {
|
||||
count?: number; // 期望生成张数(宿主会按模型上限裁剪)
|
||||
size?: string; // 形如 "1024x1024" / "auto";缺省用宿主当前配置
|
||||
};
|
||||
|
||||
export type GenerateImageResult = {
|
||||
// 生成图的 dataURL(已可直接作为 <img src> 或 three 纹理使用)
|
||||
images: string[];
|
||||
};
|
||||
|
||||
export type GenerateVideoOptions = GenerateOptions & {
|
||||
size?: string;
|
||||
seconds?: string;
|
||||
};
|
||||
|
||||
export type GenerateVideoResult = {
|
||||
url: string; // 视频可访问 URL
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export type GenerateTextOptions = {
|
||||
signal?: AbortSignal;
|
||||
model?: string;
|
||||
system?: string; // 附加系统提示词(拼在宿主系统提示之后)
|
||||
onDelta?: (text: string) => void; // 流式增量回调
|
||||
};
|
||||
|
||||
export type GenerateTextResult = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
// 一个可选模型:value 传回给 generateXxx({ model }),label 用于展示
|
||||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||
export type ModelOption = { value: string; label: string };
|
||||
|
||||
// 宿主注入的 AI 生成能力,挂在 ctx.ai 下。任何插件均可调用。
|
||||
export type CanvasPluginAi = {
|
||||
generateImage: (prompt: string, options?: GenerateImageOptions) => Promise<GenerateImageResult>;
|
||||
generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise<GenerateVideoResult>;
|
||||
generateText: (prompt: string, options?: GenerateTextOptions) => Promise<GenerateTextResult>;
|
||||
// 列出某能力下用户已配置的可选模型;不传能力则返回全部
|
||||
listModels: (capability?: ModelCapability) => ModelOption[];
|
||||
// 该能力当前默认选中的模型 value(可作为下拉框初始值)
|
||||
defaultModel: (capability: ModelCapability) => string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
|
||||
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;
|
||||
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
|
||||
ai: CanvasPluginAi;
|
||||
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
|
||||
openPanel: () => void;
|
||||
closePanel: () => 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 CanvasBuiltinPanelConfig = {
|
||||
mode: "image" | "video" | "text" | "audio"; // 生成类型,决定面板里的模型/设置项
|
||||
// 提交给模型前自动拼在用户提示词前面的固定前缀(如全景图的 equirectangular 约束)
|
||||
promptPrefix?: string;
|
||||
// true(默认)时生成结果写回本节点自身 metadata.content;false 则按内置逻辑生成到下游新节点
|
||||
writeBackToSelf?: boolean;
|
||||
};
|
||||
|
||||
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
|
||||
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||
// 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图);选中时仍显示选中描边
|
||||
transparentBackground?: boolean;
|
||||
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||
// 复用宿主内置生成面板;与自定义 Panel 二选一(同时提供时优先 Panel)
|
||||
useBuiltinPanel?: CanvasBuiltinPanelConfig;
|
||||
// 为 true 时:宿主自动在工具条加「交互 ⇄ 移动」开关,并按 metadata.interactive 控制内容层指针事件。
|
||||
// 默认(interactive 未设/为 false)为「移动」态:内容不吃指针,拖动整块移动节点;
|
||||
// 切到「交互」态后内容可点击/拖拽(如全景转视角、iframe 操作)。适合内部有交互的展示型节点。
|
||||
interactionToggle?: boolean;
|
||||
// 配合 interactionToggle:返回 true 表示当前节点内容「强制可交互」(如编辑态),
|
||||
// 此时忽略 interactive 标志、始终允许操作,并隐藏移动/交互开关。缺省视为 false。
|
||||
forceInteractive?: (node: CanvasNodeData) => boolean;
|
||||
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||
// 渲染
|
||||
Content?: ComponentType<CanvasNodeContentProps>;
|
||||
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板(自定义)
|
||||
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.1.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,161 @@
|
||||
// 便利贴节点:纯展示便利贴——整块可拖动、双击编辑、右上角自选颜色。
|
||||
// 不再声明 resource(避免宿主在右上角显示「文本N」资源角标),也不再衍生节点。
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// 预设便签色(点选切换),并额外提供自定义取色
|
||||
const PRESET_COLORS = ["#fde68a", "#fca5a5", "#fdba74", "#a7f3d0", "#bfdbfe", "#ddd6fe", "#f9a8d4", "#e7e5e4"];
|
||||
const DEFAULT_COLOR = PRESET_COLORS[0];
|
||||
|
||||
function StickyNoteContent({ ctx }: CanvasNodeContentProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
// 取色时的本地预览色:仅本组件即时重渲染,避免每次都写宿主 store
|
||||
const [draftColor, setDraftColor] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const commitTimerRef = useRef<number | null>(null);
|
||||
|
||||
// pluginColor 是插件自定义 metadata 字段(读出为 unknown,按需断言)
|
||||
const committedColor = (ctx.node.metadata?.pluginColor as string | undefined) || DEFAULT_COLOR;
|
||||
const color = draftColor ?? committedColor;
|
||||
const content = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
|
||||
// 点击便利贴外部时:退出编辑并收起调色板。
|
||||
// 监听 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上 preventDefault(会抑制
|
||||
// 兼容 mousedown 事件),所以必须用 pointerdown;capture 阶段又能先于宿主的 stopPropagation
|
||||
// 触发,避免「按 Esc 能退出、点别处退不出」。
|
||||
useEffect(() => {
|
||||
if (!editing && !paletteOpen) return;
|
||||
const onDocDown = (e: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setEditing(false);
|
||||
setPaletteOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onDocDown, true);
|
||||
return () => document.removeEventListener("pointerdown", onDocDown, true);
|
||||
}, [editing, paletteOpen]);
|
||||
|
||||
const pickColor = (next: string) => ctx.updateMetadata({ pluginColor: next });
|
||||
|
||||
// 连续取色(系统取色器拖动时 onChange 高频触发)会不停调用 updateMetadata,
|
||||
// 而宿主每次都会整表重建 + 全画布重渲染 + 持久化,导致卡顿。
|
||||
// 这里先本地预览(setDraftColor),再节流提交到宿主。
|
||||
const previewColor = (next: string) => {
|
||||
setDraftColor(next);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = window.setTimeout(() => {
|
||||
commitTimerRef.current = null;
|
||||
pickColor(next);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// 立即提交(点击预设色、取色器关闭时用):清掉待提交的节流并写入。
|
||||
const commitColor = (next: string) => {
|
||||
if (commitTimerRef.current) {
|
||||
clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
setDraftColor(next);
|
||||
pickColor(next);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
}, []);
|
||||
|
||||
// 宿主里的颜色一旦真正变化(提交完成 / 撤销重做),清掉本地预览,回到 store 为准。
|
||||
// 拖动取色期间 committedColor 不变,draftColor 得以保留,故不影响即时预览。
|
||||
useEffect(() => {
|
||||
setDraftColor(null);
|
||||
}, [committedColor]);
|
||||
|
||||
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-canvas-no-zoom
|
||||
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", background: color, borderRadius: 16, padding: 14, boxSizing: "border-box", cursor: editing ? "text" : "move", boxShadow: "inset 0 1px 0 rgba(255,255,255,.45)" }}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{/* 右上角:当前颜色小圆点,点开后自选颜色(预设 + 自定义) */}
|
||||
<div style={{ position: "absolute", top: 8, right: 8, zIndex: 5 }} onMouseDown={stop} onDoubleClick={stop}>
|
||||
<button
|
||||
type="button"
|
||||
title="选择颜色"
|
||||
onClick={() => setPaletteOpen((v) => !v)}
|
||||
style={{ width: 22, height: 22, borderRadius: "50%", border: "2px solid rgba(0,0,0,.25)", background: color, cursor: "pointer", padding: 0, boxShadow: "0 1px 3px rgba(0,0,0,.2)" }}
|
||||
/>
|
||||
{paletteOpen ? (
|
||||
<div style={{ position: "absolute", top: 28, right: 0, display: "grid", gridTemplateColumns: "repeat(4, 20px)", gap: 6, padding: 8, borderRadius: 12, background: "#fff", boxShadow: "0 8px 24px rgba(0,0,0,.18)" }}>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
title={c}
|
||||
onClick={() => {
|
||||
commitColor(c);
|
||||
setPaletteOpen(false);
|
||||
}}
|
||||
style={{ width: 20, height: 20, borderRadius: "50%", border: c === color ? "2px solid #1c1917" : "2px solid rgba(0,0,0,.12)", background: c, cursor: "pointer", padding: 0 }}
|
||||
/>
|
||||
))}
|
||||
{/* 自定义取色:点击彩环唤起系统取色器。拖动时本地预览 + 节流提交,松开(blur)时立即提交 */}
|
||||
<label title="自定义颜色" style={{ position: "relative", width: 20, height: 20, borderRadius: "50%", cursor: "pointer", background: "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)", border: "2px solid rgba(0,0,0,.12)", boxSizing: "border-box" }}>
|
||||
<input type="color" value={color} onChange={(e) => previewColor(e.target.value)} onBlur={(e) => commitColor(e.target.value)} style={{ position: "absolute", inset: 0, opacity: 0, width: "100%", height: "100%", border: "none", padding: 0, cursor: "pointer" }} />
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 内容区:双击进入编辑;非编辑态整块可直接拖动移动节点 */}
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={content}
|
||||
placeholder="输入便利贴内容…(点击别处或按 Esc 退出编辑)"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onBlur={() => setEditing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onWheel={stop}
|
||||
style={{ flex: 1, width: "100%", resize: "none", border: "none", outline: "none", background: "transparent", color: "#1c1917", fontSize: 15, lineHeight: 1.5, fontFamily: "inherit" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, whiteSpace: "pre-wrap", overflow: "hidden", color: content ? "#1c1917" : "rgba(28,25,23,.45)", fontSize: 15, lineHeight: 1.5, userSelect: "none", paddingRight: 22 }}>{content || "双击编辑便利贴"}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "sticky-note",
|
||||
name: "便利贴节点",
|
||||
version: "1.1.0",
|
||||
description: "可自选颜色、双击编辑、拖动即可移动的便利贴",
|
||||
nodes: [
|
||||
{
|
||||
type: "sticky-note:note",
|
||||
title: "便利贴",
|
||||
icon: "📌",
|
||||
description: "彩色便利贴",
|
||||
defaultSize: { width: 240, height: 200 },
|
||||
defaultMetadata: { content: "", pluginColor: DEFAULT_COLOR },
|
||||
minimapColor: "#f59e0b",
|
||||
// 纯记事节点:不弹出下方生成面板(默认会是「生成图片」的提示词面板)
|
||||
hidePanel: true,
|
||||
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.1.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,97 @@
|
||||
// SVG 节点:编辑与渲染 SVG,透明背景直接融入画布;无自身内容时可取上游文本节点里的 SVG 源码。
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
function SvgContent({ ctx }: CanvasNodeContentProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stored = ctx.node.metadata?.content as string | undefined;
|
||||
const upstream = ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.find((text): text is string => typeof text === "string" && text.trim().startsWith("<svg"));
|
||||
const value = stored ?? "";
|
||||
const svg = value.trim() || upstream || "";
|
||||
|
||||
// 无自身内容但有上游 SVG:自动采用上游源码
|
||||
useEffect(() => {
|
||||
if (stored === undefined && upstream) ctx.updateMetadata({ content: upstream });
|
||||
}, [ctx, stored, upstream]);
|
||||
|
||||
// 点击节点外部时退出编辑。用 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上
|
||||
// preventDefault 会抑制 mousedown,capture 又能先于宿主 stopPropagation 触发。
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const onDocDown = (e: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setEditing(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onDocDown, true);
|
||||
return () => document.removeEventListener("pointerdown", onDocDown, true);
|
||||
}, [editing]);
|
||||
|
||||
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
|
||||
const toggle = { position: "absolute", right: 8, top: 8, zIndex: 20, width: 32, height: 32, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: `${ctx.theme.toolbar.panel}dd`, color: ctx.theme.node.text, cursor: "pointer" } as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-canvas-no-zoom
|
||||
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", cursor: editing ? "text" : "move" }}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<button type="button" style={toggle} onMouseDown={stop} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
|
||||
{editing ? "👁" : "✎"}
|
||||
</button>
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="粘贴 SVG 源码,如 <svg …>…</svg>"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onWheel={stop}
|
||||
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, padding: 16, boxSizing: "border-box", fontFamily: "monospace", fontSize: 12, outline: "none", border: `1px solid ${ctx.theme.node.stroke}`, color: ctx.theme.node.text }}
|
||||
/>
|
||||
) : svg ? (
|
||||
// pointerEvents:none 让整块可拖动移动,双击/拖拽都命中外层节点
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 12, boxSizing: "border-box", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
) : (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, boxSizing: "border-box", color: ctx.theme.node.placeholder, fontSize: 13, textAlign: "center", pointerEvents: "none" }}>双击编辑,粘贴 SVG 源码</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "svg",
|
||||
name: "SVG 节点",
|
||||
version: "1.1.0",
|
||||
description: "透明背景渲染 SVG 矢量图,可接收上游文本节点的 SVG 源码",
|
||||
nodes: [
|
||||
{
|
||||
type: "svg:vector",
|
||||
title: "SVG",
|
||||
icon: "🔷",
|
||||
description: "渲染 SVG 矢量图",
|
||||
defaultSize: { width: 320, height: 320 },
|
||||
defaultMetadata: {},
|
||||
minimapColor: "#14b8a6",
|
||||
// 背景/边框透明,矢量图直接融入画布
|
||||
transparentBackground: true,
|
||||
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": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
@@ -31,6 +32,7 @@
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"three": "^0.180.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12",
|
||||
},
|
||||
@@ -40,6 +42,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@types/three": "^0.180.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"prettier": "^3.8.3",
|
||||
"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/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/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=="],
|
||||
|
||||
"@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/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/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/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=="],
|
||||
|
||||
"@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__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/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/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/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/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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 由 nginx 官方镜像的入口在启动前自动执行(/docker-entrypoint.d/*.sh),随后 nginx 正常拉起。
|
||||
# 从环境变量生成运行期配置 config.js;每家统计一个独立变量,未设置的留空,
|
||||
# 前端据此判定该家「关闭」,不加载对应脚本、不发外部请求。可同时启用多家。
|
||||
|
||||
# GA4 / 百度 ID 只含字母、数字和连字符;过滤掉其它字符,
|
||||
# 避免值里的引号等破坏 config.js 的 JS 字符串(纵深防御)。
|
||||
sanitize_id() {
|
||||
printf '%s' "$1" | tr -cd 'A-Za-z0-9-'
|
||||
}
|
||||
|
||||
GA4_ID=$(sanitize_id "${ANALYTICS_GA4_ID:-}")
|
||||
BAIDU_ID=$(sanitize_id "${ANALYTICS_BAIDU_ID:-}")
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
ANALYTICS_GA4_ID: "${GA4_ID}",
|
||||
ANALYTICS_BAIDU_ID: "${BAIDU_ID}"
|
||||
};
|
||||
EOF
|
||||
@@ -17,6 +17,7 @@
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
|
||||
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
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} />
|
||||
</Modal>
|
||||
);
|
||||
@@ -90,7 +90,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
placeholder="搜索资产"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => {
|
||||
@@ -122,7 +122,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
|
||||
))}
|
||||
</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 && (
|
||||
|
||||
@@ -116,11 +116,11 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<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>
|
||||
<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 className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative rounded-xl">
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
|
||||
@@ -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 fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const currentModel = node.metadata?.model;
|
||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
@@ -156,6 +156,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type ConnectionHandle, type Position } from "@/types/canvas";
|
||||
|
||||
export type PendingConnectionCreate = {
|
||||
connection: ConnectionHandle;
|
||||
position: Position;
|
||||
};
|
||||
|
||||
export function ConnectionCreateMenu({
|
||||
pending,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
pending: PendingConnectionCreate;
|
||||
onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
|
||||
data-connection-create-menu
|
||||
style={{ left: pending.position.x, top: pending.position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
引用该节点生成
|
||||
</span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<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="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition"
|
||||
style={{ color: theme.node.text }}
|
||||
onClick={onClick}
|
||||
onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)}
|
||||
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
|
||||
{description ? (
|
||||
<span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
|
||||
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 (
|
||||
<div
|
||||
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
|
||||
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
选择节点
|
||||
</span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{definitions.map((def) => (
|
||||
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { randomId } from "@/lib/utils";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
||||
@@ -46,9 +47,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, canvasContext, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useAgentStore();
|
||||
// 逐字段 selector + useShallow:只有这些字段变化时才重渲染。
|
||||
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore(
|
||||
useShallow((state) => ({
|
||||
width: state.width,
|
||||
url: state.url,
|
||||
token: state.token,
|
||||
connected: state.connected,
|
||||
enabled: state.enabled,
|
||||
prompt: state.prompt,
|
||||
attachments: state.attachments,
|
||||
sending: state.sending,
|
||||
waiting: state.waiting,
|
||||
messages: state.messages,
|
||||
eventLogs: state.eventLogs,
|
||||
threads: state.threads,
|
||||
activeThreadId: state.activeThreadId,
|
||||
workspacePath: state.workspacePath,
|
||||
loadingThreads: state.loadingThreads,
|
||||
activeTab: state.activeTab,
|
||||
confirmTools: state.confirmTools,
|
||||
activity: state.activity,
|
||||
connectError: state.connectError,
|
||||
pendingTool: state.pendingTool,
|
||||
})),
|
||||
);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const pushMessage = useAgentStore((state) => state.addMessage);
|
||||
const pushEventLog = useAgentStore((state) => state.addEventLog);
|
||||
const clearEventLogs = useAgentStore((state) => state.clearEventLogs);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const canvasContextRef = useRef(canvasContext);
|
||||
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
|
||||
const confirmToolsRef = useRef(confirmTools);
|
||||
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
||||
const autoConnectRef = useRef(false);
|
||||
@@ -81,9 +113,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
}
|
||||
}, [endpoint, setAgentState, token]);
|
||||
|
||||
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
|
||||
useEffect(() => {
|
||||
canvasContextRef.current = canvasContext;
|
||||
}, [canvasContext]);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = useAgentStore.subscribe((state) => {
|
||||
if (state.canvasContext === canvasContextRef.current) return;
|
||||
canvasContextRef.current = state.canvasContext;
|
||||
if (!useAgentStore.getState().connected) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContextRef.current?.snapshot || null), 300);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [endpoint, token]);
|
||||
useEffect(() => {
|
||||
confirmToolsRef.current = confirmTools;
|
||||
}, [confirmTools]);
|
||||
@@ -156,12 +200,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
if (connected) void loadThreads();
|
||||
}, [connected, loadThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [canvasContext?.snapshot, connected, endpoint, token]);
|
||||
|
||||
const sendPrompt = async () => {
|
||||
const text = prompt.trim();
|
||||
const files = attachments;
|
||||
@@ -175,7 +213,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
|
||||
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
|
||||
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }),
|
||||
});
|
||||
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
|
||||
const data = (await res.json()) as { threadId?: string };
|
||||
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
||||
@@ -211,12 +253,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
||||
const prev = useAgentStore.getState().attachments;
|
||||
try {
|
||||
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
||||
const dataUrl = await readDataUrl(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
attachmentUrlsRef.current.add(url);
|
||||
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
|
||||
}));
|
||||
const next = await Promise.all(
|
||||
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
||||
const dataUrl = await readDataUrl(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
attachmentUrlsRef.current.add(url);
|
||||
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
|
||||
}),
|
||||
);
|
||||
const merged = [...prev, ...next];
|
||||
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
next.forEach((item) => {
|
||||
@@ -295,7 +339,12 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||
setAgentState({ activity: "工具完成", waiting: true });
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
|
||||
addMessage({
|
||||
role: "tool",
|
||||
title: `${toolName(payload.name)}完成`,
|
||||
text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成",
|
||||
detail: { requestId: payload.requestId, name: payload.name, input, result },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "画布操作失败";
|
||||
setAgentState({ activity: "工具失败", waiting: false });
|
||||
@@ -457,7 +506,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
if (next.streamId) {
|
||||
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
|
||||
if (index >= 0) {
|
||||
setAgentState({ messages: currentMessages.map((message, i) => i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message) });
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -554,7 +603,15 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
{messages.map((item) => (
|
||||
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
|
||||
))}
|
||||
{pendingTool ? <AgentPendingToolCard summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)} detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
|
||||
{pendingTool ? (
|
||||
<AgentPendingToolCard
|
||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
|
||||
theme={theme}
|
||||
onReject={rejectPendingTool}
|
||||
onApprove={approvePendingTool}
|
||||
/>
|
||||
) : null}
|
||||
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
|
||||
</div>
|
||||
<AgentChatComposer
|
||||
@@ -569,7 +626,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
onStop={stopTurn}
|
||||
onAddFiles={addAttachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
|
||||
left={
|
||||
attachments.length ? (
|
||||
<span className="text-[11px]" style={{ color: theme.node.muted }}>
|
||||
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -580,7 +643,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
return embedded ? content : null;
|
||||
}
|
||||
|
||||
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
|
||||
function AgentLogView({
|
||||
logs,
|
||||
theme,
|
||||
context,
|
||||
onClear,
|
||||
onCopied,
|
||||
onCopyBlocked,
|
||||
}: {
|
||||
logs: AgentEventLog[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
context: AgentLogContext;
|
||||
onClear: () => void;
|
||||
onCopied: (text: string) => void;
|
||||
onCopyBlocked: (text: string) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"text" | "json">("text");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||
@@ -601,12 +678,28 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
|
||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
|
||||
<Segmented
|
||||
size="small"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as "text" | "json")}
|
||||
options={[
|
||||
{ label: "排查日志", value: "text" },
|
||||
{ label: "原始 JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} 条</span>
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>复制</Button>
|
||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>最近错误</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>清空</Button>
|
||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||
{logs.length} 条
|
||||
</span>
|
||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
|
||||
复制
|
||||
</Button>
|
||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
|
||||
最近错误
|
||||
</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -622,7 +715,29 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
|
||||
);
|
||||
}
|
||||
|
||||
function AgentConnectView({ theme, url, token, enabled, connected, activity, connectError, onUrlChange, onTokenChange, onToggleEnabled }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; url: string; token: string; enabled: boolean; connected: boolean; activity: string; connectError: string; onUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onToggleEnabled: () => void }) {
|
||||
function AgentConnectView({
|
||||
theme,
|
||||
url,
|
||||
token,
|
||||
enabled,
|
||||
connected,
|
||||
activity,
|
||||
connectError,
|
||||
onUrlChange,
|
||||
onTokenChange,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
url: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
activity: string;
|
||||
connectError: string;
|
||||
onUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onToggleEnabled: () => void;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
@@ -632,7 +747,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
};
|
||||
const codexPluginReminder = (
|
||||
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
<div className="font-medium" style={{ color: theme.node.text }}>Codex 插件提醒</div>
|
||||
<div className="font-medium" style={{ color: theme.node.text }}>
|
||||
Codex 插件提醒
|
||||
</div>
|
||||
<div className="mt-1">只有安装 Codex 插件或手动添加 MCP 后,工具列表才会进入 Codex 上下文并增加 token 消耗;仅运行 `npx -y @basketikun/canvas-agent` 启动本地 Agent 不会安装 MCP。</div>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
{[
|
||||
@@ -640,7 +757,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
|
||||
].map(([label, command]) => (
|
||||
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>{label}</span>
|
||||
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>
|
||||
{label}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||
<Tooltip title="复制命令">
|
||||
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
|
||||
@@ -666,7 +785,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
<Fragment key={step.title}>
|
||||
<div className="rounded-lg px-3 py-2.5">
|
||||
<div className="text-sm font-medium leading-5">{step.title}</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{step.text}
|
||||
</div>
|
||||
{command ? (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
|
||||
@@ -686,7 +807,10 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}>
|
||||
<span
|
||||
className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4"
|
||||
style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}
|
||||
>
|
||||
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
</span>
|
||||
@@ -714,7 +838,13 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
连接 Token
|
||||
<span className="font-normal opacity-70">Connect token</span>
|
||||
</span>
|
||||
<Input.Password size="large" prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={token} onChange={(event) => onTokenChange(event.target.value)} placeholder="自动发现,或手动填入 Connect token" />
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />}
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="自动发现,或手动填入 Connect token"
|
||||
/>
|
||||
</label>
|
||||
{connectError ? (
|
||||
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
|
||||
@@ -728,14 +858,38 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
|
||||
);
|
||||
}
|
||||
|
||||
function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loading, connected, onRefresh, onNewThread, onResumeThread, onDeleteThread }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; threads: AgentThreadSummary[]; activeThreadId: string; workspacePath: string; loading: boolean; connected: boolean; onRefresh: () => void; onNewThread: () => void; onResumeThread: (threadId: string) => void; onDeleteThread: (thread: AgentThreadSummary) => void }) {
|
||||
function AgentHistoryView({
|
||||
theme,
|
||||
threads,
|
||||
activeThreadId,
|
||||
workspacePath,
|
||||
loading,
|
||||
connected,
|
||||
onRefresh,
|
||||
onNewThread,
|
||||
onResumeThread,
|
||||
onDeleteThread,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
threads: AgentThreadSummary[];
|
||||
activeThreadId: string;
|
||||
workspacePath: string;
|
||||
loading: boolean;
|
||||
connected: boolean;
|
||||
onRefresh: () => void;
|
||||
onNewThread: () => void;
|
||||
onResumeThread: (threadId: string) => void;
|
||||
onDeleteThread: (thread: AgentThreadSummary) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0">工作空间</span>
|
||||
<span className="min-w-0 truncate" title={workspacePath}>{workspacePath || "默认画布目录"}</span>
|
||||
<span className="min-w-0 truncate" title={workspacePath}>
|
||||
{workspacePath || "默认画布目录"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm" style={{ color: theme.node.muted }}>
|
||||
@@ -758,7 +912,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{active ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>当前</span> : null}
|
||||
{active ? (
|
||||
<span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
|
||||
@@ -789,7 +947,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
|
||||
|
||||
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||
try {
|
||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }) });
|
||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -835,10 +997,12 @@ function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
||||
`pendingTool: ${context.pendingTool ? toolName(context.pendingTool) : "none"}`,
|
||||
`logs: ${logs.length}`,
|
||||
].join("\n");
|
||||
const body = logs.map((item, index) => {
|
||||
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
|
||||
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
|
||||
}).join("\n\n---\n\n");
|
||||
const body = logs
|
||||
.map((item, index) => {
|
||||
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
|
||||
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
return [head, body || "暂无事件日志"].join("\n\n");
|
||||
}
|
||||
|
||||
@@ -927,8 +1091,8 @@ function siteToolSummary(name: string, result: unknown) {
|
||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个素材`;
|
||||
if (name === "assets_add") return "已加入我的素材";
|
||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||
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_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||
return "已完成";
|
||||
@@ -958,7 +1122,12 @@ function toolSummary(item?: AgentEventItem) {
|
||||
|
||||
function parseToolResult(result: unknown) {
|
||||
const content = objectField(result, "content");
|
||||
const text = Array.isArray(content) ? content.map((item) => objectField(item, "text")).filter((item): item is string => typeof item === "string").join("\n") : "";
|
||||
const text = Array.isArray(content)
|
||||
? content
|
||||
.map((item) => objectField(item, "text"))
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join("\n")
|
||||
: "";
|
||||
try {
|
||||
return text ? JSON.parse(text) : result;
|
||||
} catch {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
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 }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
@@ -113,7 +114,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
|
||||
>
|
||||
{nodes.map((node) => {
|
||||
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 (
|
||||
<div
|
||||
key={node.id}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
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 { 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;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
extraTools?: CanvasNodeToolbarItem[];
|
||||
};
|
||||
|
||||
type ToolbarTool = {
|
||||
@@ -72,6 +74,7 @@ export function CanvasNodeHoverToolbar({
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
extraTools = [],
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||
@@ -137,7 +140,7 @@ export function CanvasNodeHoverToolbar({
|
||||
];
|
||||
const nodeToolbarTools: ToolbarTool[] = [
|
||||
...(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) }] : []),
|
||||
...(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) }] : []),
|
||||
@@ -150,7 +153,7 @@ export function CanvasNodeHoverToolbar({
|
||||
...(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 })) : []),
|
||||
];
|
||||
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 closeImageToolSettings = () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasPromptChipInput } from "./canvas-prompt-chip-input";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
@@ -25,13 +25,14 @@ type CanvasNodePromptPanelProps = {
|
||||
onStop: (nodeId: string) => void;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||
modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
|
||||
};
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const mode = defaultMode(node.type);
|
||||
const mode = modeOverride ?? defaultMode(node.type);
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
@@ -62,13 +63,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CanvasResourceMentionTextarea
|
||||
<CanvasPromptChipInput
|
||||
value={prompt}
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
onSubmit={submit}
|
||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||
/>
|
||||
|
||||
@@ -77,7 +78,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
@@ -89,16 +90,16 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</>
|
||||
) : mode === "video" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
</>
|
||||
) : mode === "audio" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -134,9 +135,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 fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const currentModel = node.metadata?.model;
|
||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
@@ -144,6 +145,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||||
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
@@ -23,8 +26,9 @@ type CanvasNodeProps = {
|
||||
editRequestNonce?: number;
|
||||
showPanel: boolean;
|
||||
showImageInfo: boolean;
|
||||
resourceLabel?: CanvasResourceReference;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
pluginHost?: CanvasPluginHost;
|
||||
registryVersion?: number;
|
||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
batchCount?: number;
|
||||
@@ -36,6 +40,7 @@ type CanvasNodeProps = {
|
||||
batchRecovering?: boolean;
|
||||
batchMotion?: { x: number; y: number; index: number };
|
||||
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
|
||||
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
|
||||
onHoverStart: (nodeId: string) => void;
|
||||
onHoverEnd: (nodeId: string) => void;
|
||||
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
||||
@@ -61,6 +66,7 @@ type NodeContentRendererProps = {
|
||||
batchOpening: boolean;
|
||||
batchRecovering: boolean;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
pluginContext?: CanvasNodeContext | null;
|
||||
onContentChange: (nodeId: string, content: string) => void;
|
||||
onStopEditing: () => void;
|
||||
mentionReferences: CanvasResourceReference[];
|
||||
@@ -82,8 +88,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
editRequestNonce = 0,
|
||||
showPanel,
|
||||
showImageInfo,
|
||||
resourceLabel,
|
||||
mentionReferences = [],
|
||||
pluginHost,
|
||||
renderPanel,
|
||||
renderNodeContent,
|
||||
batchCount = 0,
|
||||
@@ -95,6 +101,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
batchRecovering = false,
|
||||
batchMotion,
|
||||
onMouseDown,
|
||||
onSelectCapture,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onConnectStart,
|
||||
@@ -110,6 +117,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
}: CanvasNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const definition = getNodeDefinition(data.type);
|
||||
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
|
||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
||||
@@ -118,7 +127,14 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
const isGroup = data.type === CanvasNodeType.Group;
|
||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||
// 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
|
||||
// forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
|
||||
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
|
||||
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
|
||||
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
|
||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||
// 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
|
||||
const transparentBg = Boolean(definition?.transparentBackground);
|
||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -259,7 +275,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
startTop: data.position.y,
|
||||
startWidth: data.width,
|
||||
startHeight: data.height,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
|
||||
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
|
||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||
};
|
||||
window.addEventListener("mousemove", handleResizeMove);
|
||||
@@ -292,47 +308,50 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
setHovered(false);
|
||||
onHoverEnd(data.id);
|
||||
}}
|
||||
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
|
||||
onContextMenu={(event) => onContextMenu(event, data.id)}
|
||||
>
|
||||
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
maxLength={64}
|
||||
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
|
||||
style={{ borderColor: theme.node.muted, color: theme.node.text }}
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
onBlur={finishTitleEditing}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") finishTitleEditing();
|
||||
if (event.key === "Escape") {
|
||||
setTitleDraft(data.title || "");
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
|
||||
style={{ color: theme.node.text }}
|
||||
title="双击修改节点名称"
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
>
|
||||
{data.title || "未命名节点"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(isSelected || hovered || isEditingTitle) && (
|
||||
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
maxLength={64}
|
||||
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
|
||||
style={{ borderColor: theme.node.muted, color: theme.node.text }}
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
onBlur={finishTitleEditing}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") finishTitleEditing();
|
||||
if (event.key === "Escape") {
|
||||
setTitleDraft(data.title || "");
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
|
||||
style={{ color: theme.node.text }}
|
||||
title="双击修改节点名称"
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
>
|
||||
{data.title || "未命名节点"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
||||
style={{
|
||||
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
||||
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
|
||||
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke,
|
||||
borderStyle: isGroup ? "dashed" : "solid",
|
||||
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
|
||||
}}
|
||||
@@ -343,6 +362,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onToggleBatch?.(data.id);
|
||||
return;
|
||||
}
|
||||
if (definition?.onDoubleClick && pluginContext) {
|
||||
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
||||
event.stopPropagation();
|
||||
onViewImage?.(data);
|
||||
@@ -357,7 +380,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={
|
||||
{
|
||||
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
|
||||
pointerEvents: contentInteractive ? undefined : "none",
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||
@@ -377,6 +401,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
batchOpening={batchOpening}
|
||||
batchRecovering={batchRecovering}
|
||||
renderNodeContent={renderNodeContent}
|
||||
pluginContext={pluginContext}
|
||||
mentionReferences={mentionReferences}
|
||||
onContentChange={onContentChange}
|
||||
onStopEditing={() => setIsEditingContent(false)}
|
||||
@@ -389,7 +414,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
|
||||
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
||||
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
|
||||
|
||||
{!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||
|
||||
@@ -400,9 +424,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
|
||||
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[600px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -413,8 +437,16 @@ function NodeContent(props: NodeContentRendererProps) {
|
||||
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
||||
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
|
||||
if (Renderer) return <Renderer {...props} />;
|
||||
|
||||
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
|
||||
const definition = getNodeDefinition(props.node.type);
|
||||
if (definition?.Content && props.pluginContext) {
|
||||
const PluginContent = definition.Content;
|
||||
return <PluginContent ctx={props.pluginContext} />;
|
||||
}
|
||||
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
@@ -473,10 +505,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
);
|
||||
}
|
||||
|
||||
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
|
||||
未知节点
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
|
||||
<Puzzle className="size-7 opacity-40" />
|
||||
<span className="text-sm">缺少插件</span>
|
||||
<span className="text-[11px] opacity-70">节点类型 “{type}” 的插件未安装或未启用</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -533,14 +567,6 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) {
|
||||
return (
|
||||
<span className={`pointer-events-none absolute right-2 top-2 z-30 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${reference.active ? "bg-[#2f80ff] text-white shadow-sm" : "bg-black/35 text-white/75"}`}>
|
||||
{reference.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
if (!props.node.metadata?.content && props.isBatchRoot) {
|
||||
const content =
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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, hasUpgrade, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
|
||||
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
|
||||
const installedControls = (record: InstalledPlugin, upgradable = false) => (
|
||||
<>
|
||||
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
|
||||
{!record.local && (
|
||||
<>
|
||||
<Button
|
||||
type={upgradable ? "primary" : "text"}
|
||||
size="small"
|
||||
icon={<RefreshCw className="size-4" />}
|
||||
loading={busyId === record.id}
|
||||
title={upgradable ? "有新版本,点击升级" : "从来源更新"}
|
||||
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")}
|
||||
/>
|
||||
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// 图标外挂一个绿点(右上角),用于「有可升级版本」的提示。
|
||||
// boxShadow 画一圈与卡片同色的描边环,让绿点从图标上「浮起」。
|
||||
const withUpgradeDot = (icon: ReactNode) => (
|
||||
<span className="relative inline-flex">
|
||||
{icon}
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title="有新版本可升级" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const versionTag = (version: string) => (
|
||||
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||
v{version}
|
||||
</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);
|
||||
// 已安装且远程版本更高 → 显示绿点并高亮升级按钮
|
||||
const upgradable = Boolean(record && hasUpgrade(record.version, entry.version));
|
||||
const icon = entry.icon || <Puzzle className="size-4" />;
|
||||
return row(
|
||||
entry.id,
|
||||
upgradable ? withUpgradeDot(icon) : icon,
|
||||
entry.name,
|
||||
// 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
|
||||
upgradable && record ? `${record.version} → ${entry.version}` : entry.version,
|
||||
entry.description,
|
||||
record ? (
|
||||
installedControls(record, upgradable)
|
||||
) : (
|
||||
<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,425 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Image } from "antd";
|
||||
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
references: CanvasResourceReference[];
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type MentionState = {
|
||||
query: string;
|
||||
rect: DOMRect | null;
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "reference"; label: string };
|
||||
|
||||
// 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。
|
||||
// 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。
|
||||
export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
// 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声,
|
||||
// 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。
|
||||
const lastEmittedRef = useRef(value);
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
const activeReferences = useMemo(() => references.filter((item) => item.active), [references]);
|
||||
const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]);
|
||||
// 长 label 优先匹配,避免「图片1」把「图片10」切坏。
|
||||
const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]);
|
||||
const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (!mention) return [];
|
||||
const query = mention.query.trim().toLowerCase();
|
||||
if (!query) return activeReferences;
|
||||
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||||
}, [mention, activeReferences]);
|
||||
|
||||
// DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
if (document.activeElement === editor && value === lastEmittedRef.current) return;
|
||||
editor.textContent = "";
|
||||
tokens.forEach((token) => {
|
||||
if (token.type === "text") {
|
||||
editor.append(document.createTextNode(token.value));
|
||||
return;
|
||||
}
|
||||
const reference = referenceByLabel.get(token.label);
|
||||
if (reference) editor.append(createReferenceChip(reference, theme, setImagePreview));
|
||||
else editor.append(document.createTextNode(token.label));
|
||||
});
|
||||
lastEmittedRef.current = value;
|
||||
}, [tokens, referenceByLabel, theme, value]);
|
||||
|
||||
const emit = (next: string) => {
|
||||
lastEmittedRef.current = next;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const syncFromEditor = () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
emit(serializeEditor(editor));
|
||||
syncMention();
|
||||
};
|
||||
|
||||
const syncMention = () => {
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match || !activeReferences.length) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
setMention({ query: match[1] || "", rect: caretRect() });
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const closeMention = () => {
|
||||
setMention(null);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const insertReference = (reference: CanvasResourceReference) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
removeActiveMention();
|
||||
const chip = createReferenceChip(reference, theme, setImagePreview);
|
||||
const space = document.createTextNode(" ");
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (range) {
|
||||
range.insertNode(space);
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(space);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
} else {
|
||||
editor.append(chip, space);
|
||||
placeCaretAtEnd(editor);
|
||||
}
|
||||
closeMention();
|
||||
emit(serializeEditor(editor));
|
||||
};
|
||||
|
||||
const showPlaceholder = !value.trim();
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{showPlaceholder && placeholder ? (
|
||||
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-5" style={{ color: theme.node.placeholder }}>
|
||||
{placeholder}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
className={`${className || ""} overflow-y-auto whitespace-pre-wrap break-words outline-none`}
|
||||
style={{ ...style, cursor: "text" }}
|
||||
onInput={() => {
|
||||
if (!composingRef.current) syncFromEditor();
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
syncFromEditor();
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (isImeComposing(event)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
|
||||
event.preventDefault();
|
||||
requestAnimationFrame(syncFromEditor);
|
||||
return;
|
||||
}
|
||||
if (isPlainEnterKey(event) && onSubmit) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(syncMention);
|
||||
}}
|
||||
onBlur={() => window.setTimeout(closeMention, 120)}
|
||||
/>
|
||||
{mention && candidates.length ? (
|
||||
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
|
||||
) : null}
|
||||
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionMenu({ rect, references, activeIndex, theme, onSelect }: { rect: DOMRect | null; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex, references]);
|
||||
|
||||
const selectReference = (reference: CanvasResourceReference) => {
|
||||
if (selectedRef.current) return;
|
||||
selectedRef.current = true;
|
||||
onSelect(reference);
|
||||
};
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||||
|
||||
const menuWidth = 256;
|
||||
const maxMenuHeight = 224;
|
||||
const gap = 6;
|
||||
const anchor = rect || new DOMRect(16, 16, 0, 0);
|
||||
const left = clamp(anchor.left, 8, window.innerWidth - menuWidth - 8);
|
||||
const showAbove = anchor.bottom + gap + maxMenuHeight > window.innerHeight && anchor.top - gap - maxMenuHeight >= 0;
|
||||
const top = showAbove ? anchor.top - gap - maxMenuHeight : anchor.bottom + gap;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-canvas-resource-mention-menu="true"
|
||||
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
|
||||
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{references.map((reference, index) => (
|
||||
<button
|
||||
key={reference.id}
|
||||
ref={index === activeIndex ? activeItemRef : undefined}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectReference(reference);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectReference(reference);
|
||||
}}
|
||||
>
|
||||
<ReferencePreview reference={reference} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{reference.label}</span>
|
||||
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
|
||||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function createReferenceChip(reference: CanvasResourceReference, theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.contentEditable = "false";
|
||||
wrapper.dataset.refLabel = reference.label;
|
||||
if (reference.kind === "image" && reference.previewUrl) {
|
||||
const image = document.createElement("img");
|
||||
image.src = reference.previewUrl;
|
||||
image.alt = reference.title;
|
||||
image.className = "size-6 rounded object-cover";
|
||||
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
|
||||
wrapper.appendChild(image);
|
||||
wrapper.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onImagePreview(reference.previewUrl || "");
|
||||
});
|
||||
} else {
|
||||
wrapper.className = "mx-px inline-flex h-6 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||||
Object.assign(wrapper.style, { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text } as CSSProperties);
|
||||
wrapper.title = reference.text || reference.title;
|
||||
const text = document.createElement("span");
|
||||
text.className = "block truncate";
|
||||
text.textContent = reference.kind === "text" ? reference.text || reference.title : reference.label;
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function serializeEditor(editor: HTMLElement) {
|
||||
return serializeNodes(editor.childNodes).replace(//g, "");
|
||||
}
|
||||
|
||||
function serializeNodes(nodes: NodeListOf<ChildNode>) {
|
||||
let result = "";
|
||||
nodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const label = node.dataset.refLabel;
|
||||
if (label) result += label;
|
||||
else if (node.tagName === "BR") result += "\n";
|
||||
else result += serializeNodes(node.childNodes);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeActiveMention() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match) return;
|
||||
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
// chip 是 contentEditable="false" 的原子块,光标紧邻它按 Backspace/Delete 时整块删除。
|
||||
function deleteAdjacentReference(key: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = adjacentReferenceNode(range, key);
|
||||
if (!target) return false;
|
||||
const nextCaretNode = document.createTextNode("");
|
||||
target.replaceWith(nextCaretNode);
|
||||
range.setStart(nextCaretNode, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adjacentReferenceNode(range: Range, key: string) {
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const previous = key === "Backspace";
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container.textContent || "";
|
||||
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
|
||||
return findReferenceSibling(container, previous);
|
||||
}
|
||||
const children = Array.from(container.childNodes);
|
||||
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
|
||||
}
|
||||
|
||||
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
|
||||
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
|
||||
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
|
||||
return current instanceof HTMLElement && current.dataset.refLabel ? current : null;
|
||||
}
|
||||
|
||||
function textBeforeCaret() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return "";
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
const editor = closestEditor(range.startContainer);
|
||||
if (!editor) return "";
|
||||
range.setStart(editor, 0);
|
||||
return range.toString();
|
||||
}
|
||||
|
||||
function caretRect(): DOMRect | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return null;
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
range.collapse(true);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width || rect.height || rect.left || rect.top) return rect;
|
||||
// 空行/空编辑器时 range 无尺寸,退回到编辑器盒子。
|
||||
const editor = closestEditor(range.startContainer);
|
||||
return editor ? editor.getBoundingClientRect() : null;
|
||||
}
|
||||
|
||||
function closestEditor(node: Node) {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
return element?.closest("[contenteditable='true']") || null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(element: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
// 按 active label(已按长度降序)把 value 文本切成「文本片段 + 命中的引用 label」。
|
||||
function parseTokens(value: string, labels: string[]): Token[] {
|
||||
if (!labels.length) return value ? [{ type: "text", value }] : [];
|
||||
const escaped = labels.map(escapeRegExp).join("|");
|
||||
const pattern = new RegExp(`(${escaped})`, "g");
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
if (match.index === undefined) continue;
|
||||
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||||
tokens.push({ type: "reference", label: match[0] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (max < min) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function CanvasRefreshShell() {
|
||||
return (
|
||||
<main className="relative h-full min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div
|
||||
className="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(circle, var(--border) 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-5 left-1/2 z-50 flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={index} className="size-8 rounded-md bg-current opacity-10" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-24 left-6 z-50 h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
|
||||
<div className="absolute left-7 top-7 h-5 w-12 rounded-sm bg-current opacity-10" />
|
||||
<div className="absolute left-28 top-16 h-6 w-16 rounded-sm bg-current opacity-10" />
|
||||
<div className="absolute bottom-7 left-16 h-8 w-20 rounded-sm bg-current opacity-10" />
|
||||
<div className="absolute inset-5 rounded border border-current opacity-15" />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-5 left-5 z-50 flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
|
||||
<div className="size-8 rounded-md bg-current opacity-10" />
|
||||
<div className="size-8 rounded-md bg-current opacity-10" />
|
||||
<div className="h-1 flex-1 rounded-full bg-current opacity-10" />
|
||||
<div className="h-4 w-10 rounded bg-current opacity-10" />
|
||||
<div className="size-8 rounded-md bg-current opacity-10" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -89,9 +89,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
...(style || {}),
|
||||
color: showOverlay ? "transparent" : style?.color,
|
||||
caretColor: style?.color || theme.node.text,
|
||||
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
cursor: "text",
|
||||
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
|
||||
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
} as CSSProperties;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
||||
@@ -136,6 +138,18 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
onKeyDown?.(event);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && !mention) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === el.selectionEnd) {
|
||||
const result = deleteAdjacentLabel(value, el.selectionStart, event.key === "Backspace" ? "backward" : "forward", activeLabels);
|
||||
if (result) {
|
||||
event.preventDefault();
|
||||
updateValue(result.value, result.caret);
|
||||
requestAnimationFrame(updateSelectionState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -199,16 +213,24 @@ function MentionHighlightText({ value, labels, placeholder }: { value: string; l
|
||||
);
|
||||
}
|
||||
|
||||
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; caretIndex: number; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const menuWidth = 256;
|
||||
const maxMenuHeight = 224;
|
||||
const gap = 6;
|
||||
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
// 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
|
||||
// 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
|
||||
const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
|
||||
const caret = getCaretPoint(textarea, caretIndex);
|
||||
const caretLeft = rect.left + (caret.left - textarea.scrollLeft) * scale;
|
||||
const caretTop = rect.top + (caret.top - textarea.scrollTop) * scale;
|
||||
const left = clamp(caretLeft, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = caretTop + lineHeight + gap + maxMenuHeight > boundary.bottom && caretTop - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? caretTop - gap - maxMenuHeight : caretTop + lineHeight + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -273,6 +295,58 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
|
||||
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
|
||||
|
||||
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "-9999px";
|
||||
style.visibility = "hidden";
|
||||
style.whiteSpace = "pre-wrap";
|
||||
style.overflowWrap = "break-word";
|
||||
for (const prop of MIRROR_STYLE_PROPS) style[prop] = computed[prop];
|
||||
mirror.textContent = textarea.value.slice(0, index);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = textarea.value.slice(index) || ".";
|
||||
mirror.appendChild(marker);
|
||||
document.body.appendChild(mirror);
|
||||
const point = { left: marker.offsetLeft, top: marker.offsetTop };
|
||||
document.body.removeChild(mirror);
|
||||
return point;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// 纯 textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
|
||||
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
|
||||
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
|
||||
if (direction === "backward") {
|
||||
const before = value.slice(0, caret);
|
||||
for (const label of labels) {
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
|
||||
if (match) {
|
||||
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
|
||||
return { value: value.slice(0, start) + value.slice(caret), caret: start };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
|
||||
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
|
||||
const after = value.slice(caret);
|
||||
for (const label of labels) {
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`^(\\s*)(${escapeRegExp(label)})(?=\\s|$)`).exec(after);
|
||||
if (match) {
|
||||
return { value: value.slice(0, caret) + value.slice(caret + match[0].length), caret };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { App, Empty, Input, Popconfirm, Select, Tag } from "antd";
|
||||
import { Check, ChevronRight, Download, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
|
||||
import {
|
||||
CANVAS_SIDE_PANEL_MAX_WIDTH,
|
||||
CANVAS_SIDE_PANEL_MIN_WIDTH,
|
||||
CANVAS_SIDE_PANEL_MOTION_MS,
|
||||
useCanvasSidePanelStore,
|
||||
} from "@/stores/use-canvas-side-panel-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import type { InsertAssetPayload } from "./asset-picker-modal";
|
||||
|
||||
const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000;
|
||||
const PANEL_EASE = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
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");
|
||||
const width = useCanvasSidePanelStore((state) => state.width);
|
||||
const panelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
|
||||
const panelMounted = useCanvasSidePanelStore((state) => state.panelMounted);
|
||||
const panelClosing = useCanvasSidePanelStore((state) => state.panelClosing);
|
||||
const setWidth = useCanvasSidePanelStore((state) => state.setWidth);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = width;
|
||||
let nextWidth = startWidth;
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
nextWidth = Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, startWidth + moveEvent.clientX - startX));
|
||||
setWidth(nextWidth);
|
||||
};
|
||||
const onUp = () => {
|
||||
localStorage.setItem("canvas-side-panel-width", String(nextWidth));
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
setResizing(false);
|
||||
};
|
||||
setResizing(true);
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
if (!panelMounted) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative z-[60] flex h-full shrink-0"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
|
||||
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
|
||||
>
|
||||
<motion.aside
|
||||
className="relative flex h-full shrink-0 flex-col overflow-hidden border-r"
|
||||
initial={{ x: -48 }}
|
||||
animate={{ x: panelClosing ? -28 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
|
||||
style={{ width, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
data-canvas-no-zoom
|
||||
>
|
||||
<div className="flex items-center gap-5 px-4 pt-3.5">
|
||||
<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>
|
||||
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="relative pb-1.5 text-sm font-semibold transition-opacity" style={{ color: theme.node.text, opacity: active ? 1 : 0.45 }}>
|
||||
{label}
|
||||
{active ? <motion.span layoutId="sidePanelTabIndicator" className="absolute inset-x-0 -bottom-px h-0.5 rounded-full" style={{ background: theme.toolbar.activeText }} transition={{ type: "spring", stiffness: 500, damping: 34 }} /> : null}
|
||||
</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 { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
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]);
|
||||
|
||||
const exitSelect = () => {
|
||||
setSelectMode(false);
|
||||
setChecked(new Set());
|
||||
};
|
||||
const toggleChecked = (id: string) =>
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const allChecked = filtered.length > 0 && filtered.every((node) => checked.has(node.id));
|
||||
const toggleAll = () => setChecked(allChecked ? new Set() : new Set(filtered.map((node) => node.id)));
|
||||
|
||||
const handleExport = async () => {
|
||||
const targets = nodes.filter((node) => checked.has(node.id));
|
||||
if (!targets.length) return;
|
||||
setExporting(true);
|
||||
const hide = message.loading("正在导出选中元素…", 0);
|
||||
try {
|
||||
await exportCanvasNodes(targets, `画布元素-${targets.length}个`);
|
||||
message.success(`已导出 ${targets.length} 个元素`);
|
||||
exitSelect();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("导出失败,请重试");
|
||||
} finally {
|
||||
hide();
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}
|
||||
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
|
||||
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
|
||||
>
|
||||
<ListChecks className="size-3.5" />
|
||||
{selectMode ? "取消" : "选择"}
|
||||
</button>
|
||||
{selectMode ? null : <Select size="small" variant="borderless" className="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 isChecked = checked.has(node.id);
|
||||
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
onClick={() => (selectMode ? toggleChecked(node.id) : 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}
|
||||
>
|
||||
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
|
||||
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
|
||||
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
|
||||
</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>
|
||||
{selectMode ? (
|
||||
<div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}>
|
||||
<button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10">
|
||||
{allChecked ? "取消全选" : "全选"}
|
||||
</button>
|
||||
<span className="text-xs opacity-45">已选 {checked.size}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleExport()}
|
||||
disabled={!checked.size || exporting}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10"
|
||||
style={{ color: theme.node.text }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
导出选中
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) {
|
||||
return (
|
||||
<span className="grid size-4 shrink-0 place-items-center rounded border transition" style={{ borderColor: checked ? theme.toolbar.activeText : theme.node.stroke, background: checked ? theme.toolbar.activeText : "transparent" }}>
|
||||
{checked ? <Check className="size-3 text-white" /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 资产 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 { message } = App.useApp();
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const removeAsset = useAssetStore((state) => state.removeAsset);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [tagFilter, setTagFilter] = useState<string>("all");
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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]);
|
||||
|
||||
const handleFiles = async (fileList: FileList | null) => {
|
||||
const files = Array.from(fileList || []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
const hide = message.loading("正在添加资产…", 0);
|
||||
let added = 0;
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
const image = await uploadImage(file);
|
||||
addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } });
|
||||
added += 1;
|
||||
} else if (file.type.startsWith("video/")) {
|
||||
const media = await uploadMediaFile(file, "video");
|
||||
addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } });
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if (added) message.success(`已添加 ${added} 个资产`);
|
||||
else message.warning("仅支持图片或视频文件");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("添加失败,请重试");
|
||||
} finally {
|
||||
hide();
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
|
||||
style={{ color: theme.node.text }}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
添加
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} />
|
||||
</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} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) {
|
||||
return (
|
||||
<div className="group relative aspect-square overflow-hidden rounded-xl border transition duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||
<AssetCover asset={asset} />
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2.5 opacity-0 transition duration-200 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInsert}
|
||||
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-stone-900 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80"
|
||||
aria-label="插入画布"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
<Popconfirm title="移除该资产?" okText="移除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onRemove}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-red-500 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80 dark:hover:text-red-400"
|
||||
aria-label="移除资产"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCover({ asset }: { asset: Asset }) {
|
||||
if (asset.kind === "text") return <div className="size-full overflow-hidden whitespace-pre-wrap break-words p-2.5 text-[11px] leading-snug opacity-80">{asset.data.content}</div>;
|
||||
if (asset.kind === "video") {
|
||||
if (asset.coverUrl) return <img src={asset.coverUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
|
||||
return <video src={`${asset.data.url}#t=0.1`} muted playsInline preload="metadata" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
|
||||
}
|
||||
return <img src={asset.coverUrl || asset.data.dataUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { 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 { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function CanvasToolbar({
|
||||
onAddText,
|
||||
onAddConfig,
|
||||
onAddGroup,
|
||||
onAddExtensionNode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onUpload,
|
||||
@@ -27,7 +29,6 @@ export function CanvasToolbar({
|
||||
onDeselect,
|
||||
onBackgroundModeChange,
|
||||
onShowImageInfoChange,
|
||||
onOpenMyAssets,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
canUndo: boolean;
|
||||
@@ -40,6 +41,7 @@ export function CanvasToolbar({
|
||||
onAddText: () => void;
|
||||
onAddConfig: () => void;
|
||||
onAddGroup: () => void;
|
||||
onAddExtensionNode: (type: string) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onUpload: () => void;
|
||||
@@ -48,9 +50,9 @@ export function CanvasToolbar({
|
||||
onDeselect: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onShowImageInfoChange: (show: boolean) => void;
|
||||
onOpenMyAssets: () => void;
|
||||
}) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
@@ -58,13 +60,31 @@ export function CanvasToolbar({
|
||||
const [tipX, setTipX] = useState(0);
|
||||
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
||||
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 hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
|
||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
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 (
|
||||
<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}
|
||||
<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}>
|
||||
@@ -95,13 +115,30 @@ export function CanvasToolbar({
|
||||
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
|
||||
<Group className="size-4.5" />
|
||||
</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" />
|
||||
</ToolbarButton>
|
||||
<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
|
||||
id="tool-style"
|
||||
label="画布外观"
|
||||
@@ -114,6 +151,7 @@ export function CanvasToolbar({
|
||||
onHover={setHovered}
|
||||
onClick={(event) => {
|
||||
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||
setExtensionsOpen(false);
|
||||
setAppearanceOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
@@ -133,6 +171,36 @@ export function CanvasToolbar({
|
||||
</ToolbarButton>
|
||||
</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 ? (
|
||||
<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"
|
||||
@@ -287,8 +355,8 @@ function toolLabel(id: string) {
|
||||
if (id === "tool-audio") return "音频";
|
||||
if (id === "tool-config") return "生成配置";
|
||||
if (id === "tool-group") return "组";
|
||||
if (id === "tool-upload") return "上传素材";
|
||||
if (id === "tool-assets") return "我的素材";
|
||||
if (id === "tool-extensions") return "扩展节点";
|
||||
if (id === "tool-upload") return "上传资产";
|
||||
if (id === "tool-style") return "画布外观";
|
||||
if (id === "tool-delete") return "删除选中";
|
||||
if (id === "tool-clear") return "清空画布";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user