Compare commits

...

36 Commits

Author SHA1 Message Date
HouYunFei 0e1edd1484 chore(release): bump version to v0.9.0 and update changelog 2026-07-17 16:43:01 +08:00
HouYunFei 5107be0acf feat(canvas): 资产卡增删改重做 + 节点名称默认隐藏
- 资产 Tab 卡片重做:视频取首帧封面、文本铺内容、去标题成矩形,hover 居中显示插入/删除两个 icon 按钮
- 修复删除确认框「取消」经 React Portal 事件冒泡误触插入
- 资产 Tab 支持上传添加图片/视频、卡片移除资产
- 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现
- 操作按钮改用扁平无灰底样式,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:42:08 +08:00
HouYunFei 219b3e84db feat(canvas): 左侧面板可调宽收起 + 元素多选导出与跳转动画
- 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),状态与宽度持久化
- 顶栏菜单左侧新增面板开关按钮,并缩小菜单/开关按钮
- 顶栏菜单新增「导出当前画布」为压缩包
- 画布元素列表支持多选并批量导出:图片/视频/音频为文件、文本为 txt、其余各自为 json
- 画布/资产切换改为滑动下划线动画,点击元素跳转带缓动动画
- 移除文本/配置/视频等非图片元素图标的灰色底色,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:21:26 +08:00
HouYunFei a3411cf9d9 docs(changelog): note the drag-resize React #185 crash fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:17:57 +08:00
HouYunFei 430d1bd956 refactor(canvas-project): extract plugin host into usePluginHost hook
Move the plugin node host capabilities (pluginAi generation bridge, pluginHost
canvas accessors, renderPluginPanel, buildNodeToolbarItems) and the plugin-load
effect out of InfiniteCanvasPage into usePluginHost. The hook receives the
shared config/refs/setters explicitly and keeps identical dependency arrays.

This also relocates the AI-service and plugin imports (requestVideoGeneration,
selectableModelsByCapability, buildNodeContext, ensurePluginsLoaded, the
canvas-plugin types, etc.) that were only used by this cluster.

project.tsx is now ~2950 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:19:00 +08:00
HouYunFei cf0561d12a refactor(canvas-project): extract agent bridge into useAgentBridge hook
Move the canvas<->local-Agent bridge (agentSnapshot memo, applyAgentOps /
undoAgentOps, agentUndoSnapshot state, and the publish-to-agent-store effect)
out of InfiniteCanvasPage into a useAgentBridge hook. Only applyAgentOps is
consumed outside (by the plugin host); the rest is fully encapsulated.

The hook receives the shared canvas state/refs/setters explicitly and keeps the
exact same dependency arrays, so behavior is unchanged. project.tsx drops the
now-unused agent-ops imports and the setCanvasContext selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:14:10 +08:00
HouYunFei e6dcd7a832 refactor(canvas-project): extract CanvasTopBar into its own component
Move CanvasTopBar and its private helpers (MenuLabel, CompactAgentStatus,
Shortcut) out of project.tsx into canvas-top-bar.tsx. The component already took
all data via explicit props, so the move is mechanical with no behavior change.
Drop the imports (DOCS_URL, UserStatusActions, Dropdown, several icons) that
were only used by the top bar.

project.tsx is now ~3080 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:04:25 +08:00
HouYunFei 187c499ef9 refactor(canvas-project): extract refresh shell and create menus into components
Move the module-scope presentational subcomponents out of project.tsx:
- canvas-refresh-shell.tsx: CanvasRefreshShell (pre-load placeholder).
- canvas-create-menus.tsx: ConnectionCreateMenu, ConnectionCreateOption,
  NodeCreateMenu, plus the PendingConnectionCreate type they define.

They already communicated via explicit props, so the move is mechanical. No
behavior change; project.tsx imports them back and drops now-unused icon and
node-registry imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:00:53 +08:00
HouYunFei cf1ea2646f refactor(canvas-project): extract pure helpers into @/lib/canvas modules
Move the ~30 module-scope pure functions out of the 3.5k-line project.tsx into
three focused lib modules, with identical names and signatures. No behavior
change; project.tsx imports them back.

- canvas-node-factory.ts: createCanvasNode, *Metadata builders, referenceUrl,
  applyNodeConfigPatch.
- canvas-node-geometry.ts: nodeBounds, group drop/snap, connection anchors,
  normalizeConnection, hidden-batch checks.
- canvas-generation-helpers.ts: generation config/inputs, reference hydration,
  retry-source lookup, angle prompt builders, misc pure helpers.

project.tsx drops from 3536 to ~3230 lines. Also removes imports that were only
used by the moved helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:46:07 +08:00
HouYunFei 57db0fe752 feat(canvas-local-agent-panel): optimize state management and rendering performance 2026-07-17 13:38:55 +08:00
HouYunFei c5f53454aa feat(changelog): add support for Google Analytics 4 and Baidu statistics 2026-07-17 11:32:11 +08:00
HouYunFei ca6efdf0c3 feat(analytics): implement runtime configuration and analytics tracking for GA4 and Baidu 2026-07-17 11:24:22 +08:00
HouYunFei a2586ffe34 feat(canvas-node-manual): enhance input box to support resource references with thumbnail display 2026-07-17 11:19:34 +08:00
HouYunFei 5a27aae99e feat(changelog): update entries for canvas node enhancements and image reference display 2026-07-17 10:15:16 +08:00
HouYunFei 66961ce10f feat(canvas-prompt-chip-input): implement new component for enhanced prompt input with reference chips 2026-07-17 10:06:57 +08:00
HouYunFei 0c4288b832 chore: update changelog for v0.8.2 and bump version to v0.8.2 2026-07-16 17:44:20 +08:00
HouYunFei 093260f7b2 feat(image-settings-panel): clarify transparent background feature availability in tooltip 2026-07-16 17:44:05 +08:00
HouYunFei 805573b980 feat(prompt-chip-input): implement contentEditable input for image references with thumbnail display 2026-07-16 17:32:43 +08:00
HouYunFei e6829333c5 feat(canvas-node): enhance input area styling and improve cursor visibility in prompt panel 2026-07-16 17:28:30 +08:00
HouYunFei 2959b5d340 feat(image-settings): add transparent background toggle for image generation 2026-07-16 15:45:02 +08:00
HouYunFei 2f9776bee3 chore: bump version to v0.8.1 2026-07-16 15:12:02 +08:00
HouYunFei 4beadc6161 feat(markdown-node): improve rendering performance and user interaction by caching parsed Markdown and optimizing DOM updates 2026-07-16 15:11:48 +08:00
HouYunFei 13c508bc34 feat(svg-node): enhance SVG node with transparent background rendering and improved editing experience 2026-07-16 14:13:43 +08:00
HouYunFei 5f627d5baf feat(sticky-note): update sticky note plugin with customizable colors, improved editing experience, and enhanced drag-and-drop functionality 2026-07-16 13:57:47 +08:00
HouYunFei 3f179a607a feat(panorama): enhance 3D panorama node with AI generation support and unified interaction toggle 2026-07-16 13:21:25 +08:00
HouYunFei 2d2c7e3d5e feat(plugin-manager): add upgrade notification for installed plugins and improve version handling 2026-07-16 10:36:41 +08:00
HouYunFei 3f623ea70c feat(html-node): enhance HTML node plugin with interactive toolbar and improved editor functionality 2026-07-15 17:51:39 +08:00
HouYunFei 7ac2ab351d chore(release): bump version to v0.8.0 and update CHANGELOG 2026-07-15 16:00:17 +08:00
HouYunFei b6e75c225e Merge branch 'dev'
# Conflicts:
#	.gitignore
#	CHANGELOG.md
#	web/bun.lock
2026-07-15 15:32:32 +08:00
HouYunFei 75aafc115f feat(plugin-sdk): add TypeScript SDK for Infinite Canvas plugins with automatic JSX and build support 2026-07-15 15:31:48 +08:00
HouYunFei 8d3f244524 feat(toolbar): add extension node management and close on outside click 2026-07-15 13:10:53 +08:00
HouYunFei 88510223eb feat(plugin): add local plugin discovery and management with cache busting 2026-07-15 12:55:59 +08:00
HouYunFei eef4d96787 feat(plugin): implement canvas node plugin system with dynamic registration and remote installation 2026-07-15 11:47:25 +08:00
HouYunFei f0db29d8e3 feat(channel): add customizable JS model-call scripts with split-panel editor
- Run user-authored async scripts per model with flat locals (prompt,
  images, messages, params, model, baseUrl, apiKey, http, request, poll,
  sleep, signal, onDelta); empty script falls back to system default.
- Wire image/video/audio/text request entrypoints through the plugin
  runtime, keeping default handlers unchanged.
- Split-panel script editor: variable/return docs on the left, CodeMirror
  JS editor on the right; title shows capability - model.
- Provide OpenAI and Gemini template buttons per capability; text template
  uses the /responses API; image template branches text2img vs img2img.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:24:34 +08:00
HouYunFei 3963c6eebf feat(model): enhance model configuration and script handling for audio and video capabilities 2026-07-15 10:13:37 +08:00
HouYunFei c57f7d61a7 feat(model): enhance model configuration and script handling for audio and video capabilities 2026-07-15 10:13:32 +08:00
153 changed files with 9057 additions and 1364 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile
run: bun install
- name: Build
working-directory: web
+47
View File
@@ -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
+3
View File
@@ -11,4 +11,7 @@ data/
web/dist
**/dist
web/public/plugins
plugins/canvas/*/dist
plugins/canvas/*/node_modules
web/public/plugins
+2
View File
@@ -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
View File
@@ -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 两种方式。
+2
View File
@@ -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
+6 -24
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
v0.7.1
v0.9.0
+4
View File
@@ -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`,请手动填写模型名。
+2 -1
View File
@@ -4,6 +4,7 @@
"defaultOpen": true,
"pages": [
"local-development",
"canvas-data-structure"
"canvas-data-structure",
"local-codex-canvas"
]
}
+39
View File
@@ -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>
+15
View File
@@ -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)。
## 提示词库
前台提示词库支持:
+1 -2
View File
@@ -9,7 +9,6 @@
"render",
"docker",
"third-party-prompt-repositories",
"[在线体验](https://canvas.best/)",
"local-codex-canvas"
"[在线体验](https://canvas.best/)"
]
}
+1
View File
@@ -5,6 +5,7 @@
"pages": [
"[更新日志](/docs/progress/changelog)",
"local-agent-integration-plan",
"prompt-chip-input-plan",
"pending-test",
"todo"
]
+12 -1
View File
@@ -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` 默认 truevalue 是提示词字符串,引用以 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 ← DOMsyncFromEditor**:仿 `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. **占位符 & 样式**:空值时显示 placeholderabsolute 占位层);容器套用面板传入的 `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」文本(生成结果正常引用到图)。
- 中文输入法输入不吞字、不误触发发送。
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
-25
View File
@@ -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>
-1
View File
@@ -4,7 +4,6 @@
"defaultOpen": true,
"pages": [
"security",
"donate",
"sponsor"
]
}
+19 -5
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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() {
+5
View File
@@ -7,4 +7,9 @@ server {
location / {
try_files $uri $uri/ /index.html;
}
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
location = /config.js {
add_header Cache-Control "no-store";
}
}
+163
View File
@@ -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`(避免被画布缩放拦截)。
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+138
View File
@@ -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 }),
},
];
},
},
],
});
+17
View File
@@ -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"]
}
+23
View File
@@ -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` 字段自动注入/清理。
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+10
View File
@@ -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 };
}
+125
View File
@@ -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 }),
},
];
},
},
],
});
+59
View File
@@ -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%;
}
+17
View File
@@ -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"]
}
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
// three 从 CDN 动态加载,不打进 bundle;声明为宽松类型以便通过 tsc
declare module "https://esm.sh/three@0.180.0";
+311
View File
@@ -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: "" }),
},
]
: []),
];
},
},
],
});
+17
View File
@@ -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"]
}
+39
View File
@@ -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
```
+69
View File
@@ -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
View File
@@ -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"
}
}
}
}
+14
View File
@@ -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"
}
}
+77
View File
@@ -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` 公开契约的镜像;宿主契约变更时同步此处。
+75
View File
@@ -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`);
}
}
+535
View File
@@ -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"
}
}
}
}
+24
View File
@@ -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"
}
}
+18
View File
@@ -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 };
+8
View File
@@ -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";
+16
View File
@@ -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);
}
+38
View File
@@ -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;
}
+37
View File
@@ -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;
+331
View File
@@ -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;
+18
View File
@@ -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"]
}
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+161
View File
@@ -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,
},
],
});
+17
View File
@@ -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"]
}
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+97
View File
@@ -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,
},
],
});
+17
View File
@@ -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"]
}
+25
View File
@@ -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 的类型定义。
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+69
View File
@@ -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,
},
],
});
+17
View File
@@ -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
View File
@@ -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=="],
+22
View File
@@ -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
+1
View File
@@ -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>
+1
View File
@@ -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",
+3
View File
@@ -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,
+83 -57
View File
@@ -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]" />;
}
+79 -11
View File
@@ -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