Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e148502e | |||
| 0ea53f177f | |||
| 22638dd4ac | |||
| a2bfb0d9b5 | |||
| 33f870d16a | |||
| c311c7d7f8 | |||
| 923c82f366 | |||
| 62b41b79dc | |||
| 55d70fe786 | |||
| 7bd0bf2049 | |||
| 3ef07cd00c | |||
| d2068d416a | |||
| 97fefaa519 | |||
| c34d0b7ce0 | |||
| f28d3810d4 | |||
| 65c54e7b9e | |||
| cc2172319e | |||
| 5d82655e0e | |||
| 9b24319747 | |||
| b995e659a6 | |||
| 5803787910 | |||
| 84c50fe595 | |||
| f78618be2b | |||
| 18913efab7 | |||
| 9fa0958eb8 | |||
| 43af2aeceb | |||
| 27311e831d | |||
| ce05ab653d | |||
| 04c4566679 | |||
| 30978320c3 | |||
| 7297985829 | |||
| cb14e8e6d2 | |||
| 7cdd2db4d7 | |||
| 75342cb7ad | |||
| 7e08dd996b | |||
| b3a634ce6b | |||
| 56b83e2009 | |||
| 9abbe9c029 | |||
| e425b28064 | |||
| 9c1dd28f3a | |||
| 85b038a93f | |||
| 0ca2af7a37 | |||
| a927f36d96 | |||
| c3131a71bd | |||
| 5f2bf2d142 | |||
| 63c4098e4e | |||
| 9c6f17f236 | |||
| 9c2ecb9fe2 | |||
| c83641417b | |||
| a3d92f9cd6 | |||
| 196866aaf3 | |||
| 59b59c2f03 | |||
| 437a46ad36 | |||
| 2a9667a287 | |||
| 3e4833470a | |||
| 896e6a0656 | |||
| 17c14c85e8 | |||
| 020fb55084 | |||
| 238e4dd4c5 | |||
| 3c8953e842 | |||
| d02cd2b01d | |||
| 2b0afddd07 | |||
| f6acdac8d8 | |||
| 9ea8463867 | |||
| c491cd023c | |||
| 20bdc7d44a | |||
| e78c18b697 | |||
| 56a9806bc0 | |||
| dadfd0e41d | |||
| 5fd8312ef5 | |||
| f1c5853fea | |||
| 22119358f5 | |||
| aac3813ce4 | |||
| 8e3ad8c2a2 | |||
| db3eb8ec5d | |||
| cda4db5ec8 | |||
| fe80df375c | |||
| 4f75dcb9fd | |||
| 2a0b6bb9d9 | |||
| 412e31e898 | |||
| 6ad003eb37 | |||
| 50675ffed3 | |||
| bd728d2d94 | |||
| 6298202dd7 | |||
| f08310b83c | |||
| e0e4276e71 | |||
| 167e01af18 | |||
| 9d788e1712 | |||
| bd4f1a34a0 | |||
| dd36d4f795 | |||
| 9ae7d2d56a | |||
| bad68c6d59 | |||
| a1e3a32b61 | |||
| 7b0e734e16 | |||
| d5ab402592 | |||
| 3714af788f | |||
| 2f55f59bfc | |||
| dc0f25cad0 | |||
| 7de9a3ea1d | |||
| 3cda36b685 | |||
| 1a0d9cdc8a | |||
| 53905a4868 | |||
| 298f4b36c0 | |||
| c426e94b58 | |||
| 330d8d6e34 | |||
| ea102b9949 | |||
| efcec4d603 | |||
| e75b9ed5e4 | |||
| 74748d6e23 | |||
| 3da19e288a | |||
| 47e318682e | |||
| 9946142d5e |
@@ -1,2 +1,4 @@
|
|||||||
|
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
|
||||||
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
|
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
|
||||||
|
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
|
||||||
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
|
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
|
||||||
node_modules/
|
node_modules/
|
||||||
|
# Next 开发/生产构建缓存与产物,不作为源代码提交。
|
||||||
.next/
|
.next/
|
||||||
|
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
|
||||||
.headless-dist/
|
.headless-dist/
|
||||||
|
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
# TypeScript 增量检查缓存,由工具重新生成。
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
# macOS Finder 的目录元数据。
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
|
||||||
/docs/
|
/docs/
|
||||||
|
|||||||
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
|
|||||||
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
|
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
|
||||||
|
|
||||||
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
|
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
|
||||||
|
|
||||||
|
|
||||||
|
## 配置与生成数据阅读说明
|
||||||
|
|
||||||
|
本节为不适合内嵌注释的配置和严格 JSON 提供旁注;实际值仍以对应文件为准。源码、脚本和测试中的函数说明保留在各自文件内。包版本、TypeScript 输出目标、服务协议版本、文档格式版本和组件数据版本是不同概念。
|
||||||
|
|
||||||
|
### 应用包与执行入口
|
||||||
|
|
||||||
|
根 `package.json` 的 `private: true` 防止常规 npm 发布,`version: 0.1.0` 是应用包版本。`dependencies` 是应用运行/构建引用的库,`devDependencies` 包含编译器、类型声明和 Tailwind 工具;它们的精确版本及传递依赖由 `package-lock.json` 锁定。lock 文件由包管理器维护,不在其中手写说明或改某个嵌套版本。
|
||||||
|
|
||||||
|
| scripts 项 | 执行内容与边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dev` | 在 localhost:17321 启动 Next 开发服务器,会按需编译。 |
|
||||||
|
| `build` / `start` | 前者执行 Next 生产构建,后者在 localhost:17321 运行已有构建。 |
|
||||||
|
| `typecheck` | `tsc --noEmit` 检查类型,不生成应用 JS。 |
|
||||||
|
| `test:headless` | 运行 `tests/headless/*.test.mjs`;不自动执行所有 `.integration.mjs` 或浏览器场景。 |
|
||||||
|
| `test:headless:browser` | 浏览器存储、Worker 等适配器场景入口,环境装配见 `browser-storage.mjs`。 |
|
||||||
|
| `test:headless:proxy` | Foundry 同源代理的隔离集成入口,启动与替身链路见 `proxy.integration.mjs`。 |
|
||||||
|
| `test:headless:renderer` / `test:headless:performance` | 运行页面集成入口;后者附加 `--performance` 选择性能场景。 |
|
||||||
|
| `build:headless` | 清理并重建 `.headless-dist` 的 ESM、声明和 Worker 引用,再复制包清单/文档。 |
|
||||||
|
| `test:headless:package` | 构建、打包到隔离目录并运行消费方及浏览器验收,不是单纯语法检查。 |
|
||||||
|
|
||||||
|
集成入口可能编译或启动本地进程,具体动作见各脚本模块注释;本节列出命令不表示这些检查已经运行或通过。
|
||||||
|
|
||||||
|
### 应用 TypeScript 配置
|
||||||
|
|
||||||
|
`tsconfig.json` 面向应用检查;以下选项共同作用,并非运行时开关:
|
||||||
|
|
||||||
|
| 配置项 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `target: ES2020` / `lib` | 声明语言输出目标和可用类型库;`dom`、`dom.iterable`、`esnext` 提供 API 类型,不会安装浏览器 polyfill。 |
|
||||||
|
| `allowJs` / `skipLibCheck` | 允许 JS 进入项目;跳过声明文件内部的完整检查。它们不关闭项目 TS 源码的检查。 |
|
||||||
|
| `strict` / `noEmit` | 启用严格类型检查,默认只检查而不生成 JS/声明。 |
|
||||||
|
| `allowImportingTsExtensions` | 允许源码相对导入使用 `.ts` 等 TS 扩展名;独立包输出由另一配置重写扩展名。 |
|
||||||
|
| `esModuleInterop` | 为 CommonJS/ESM 互操作提供 TypeScript 的兼容处理,不把整个项目改成 CommonJS。 |
|
||||||
|
| `module: esnext` / `moduleResolution: bundler` | 使用 ESM 语法并按 bundler 模式解析依赖;应用最终交由 Next 构建链处理。 |
|
||||||
|
| `resolveJsonModule` | 允许 TS 引用并推断 JSON 模块,例如图标 manifest。 |
|
||||||
|
| `isolatedModules` / `jsx: react-jsx` | 按单文件转换约束检查源码,并使用 React 自动 JSX 转换形式。 |
|
||||||
|
| `incremental` | 允许 TypeScript 使用增量检查缓存;生成的 `*.tsbuildinfo` 被忽略。 |
|
||||||
|
| `paths` | `@/*` 在 TS 解析时指向 `src/*`,这不是把字符串路径写进业务文档。 |
|
||||||
|
| `plugins` | 登记 Next TypeScript 插件供支持它的工具使用。 |
|
||||||
|
| `include` / `exclude` | 纳入应用 TS/TSX、`next-env.d.ts` 与 Next 生成类型入口;排除依赖和独立包输出目录。由这些源码导入的依赖仍可参与类型解析。 |
|
||||||
|
|
||||||
|
`next-env.d.ts` 是 Next 维护的类型入口,引用框架/图片和生成路由类型。其路径会随开发/构建环境变化,不在这个生成文件中追加维护注释。
|
||||||
|
|
||||||
|
### 独立 headless 包
|
||||||
|
|
||||||
|
`tsconfig.headless.json` 继承应用配置,显式覆盖输出部分:`target`/`module` 为 ES2022,`rootDir` 为 `src/lib`,`outDir` 为 `.headless-dist/modules`;`noEmit: false` 与 `declaration: true` 生成 JS 和 `.d.ts`。`incremental: false` 不使用这次编译的增量缓存,`rewriteRelativeImportExtensions` 将可重写的相对 TS 导入转成输出扩展名。`types: []` 和 `plugins: []` 清空自动纳入的类型包列表与继承插件条目,不禁止源码显式导入类型;未覆盖的 `lib` 等项仍继承。四组 `include` 从 core/runtime/service/bindings 的 `.ts` 入口收集源码,导入关系还会带入所需依赖。
|
||||||
|
|
||||||
|
`headless/package.json` 是复制到产物根的包模板,源目录本身没有生成后的 `modules`。它的 `type: module` 声明 ESM,Node 引擎要求为 `>=22`。`exports` 为每个公开入口分别指定类型声明和 ESM 文件:根及 `/core` 指向 core,另有 `/runtime`、`/browser`、`/service`、`/bindings`、`/react-flow` 与 `/react`。这些是产物目录中的路径,不是指向 `src` 的别名,也没有声明 CommonJS `require` 入口。
|
||||||
|
|
||||||
|
`files` 限定包收录的模块、消费示例和验收文档。React、React DOM、React Flow 以带版本范围的可选 peer 声明,供选择相关适配入口的宿主提供;“可选”不表示调用 UI 适配器时可以缺少它依赖的框架。便携入口的使用和依赖边界见 `headless/README.md`。`private: true` 保持预览包私有,不代表已经发布到 registry。
|
||||||
|
|
||||||
|
### 组件生成器配置
|
||||||
|
|
||||||
|
`components.json` 是 UI 组件生成器读取的配置,不是应用运行时组件 registry。`$schema` 指向配置的 schema;`style: new-york`、`tsx: true` 和 `rsc: false` 记录生成风格、TSX 和 RSC 选项,不改变整个 App Router 的路由/渲染模式。`tailwind.css` 指向 `src/styles/globals.css`,空 `tailwind.config` 没有指定独立配置路径,`baseColor: neutral` 与 `cssVariables: true` 是生成器的配色约定。`aliases` 指示生成文件的组件、工具、UI、lib 和 hooks 导入位置;其中 `@/hooks` 不保证已经存在对应目录。
|
||||||
|
|
||||||
|
### 图标生成数据
|
||||||
|
|
||||||
|
`src/components/canvas/icon-catalog-data.json` 是检索目录数组,每项包含 `name`(持久引用的图标 ID)、`slug`、`source`、`label`、`categories`、`common` 和 `keywords`;不包含 SVG 路径。`icon-catalog.ts` 用它构建名称 Map 并执行筛选,选择器的虚拟窗口另由 UI 控制。
|
||||||
|
|
||||||
|
`src/lib/canvas-icon-assets.json` 保存 `root` URL 前缀与 `assets` 桶名到摘要文件名的映射。`iconAsset()` 对 Lucide 名称做 31 进制滚动散列并模 96,Tabler 名称统一使用 `tabler` 桶。`public/canvas-icons/<版本目录>/*.json` 每个分片以图标名索引 `[tag, props][]`,消费端 `validateIconShard()` 再检查支持的图形与属性。
|
||||||
|
|
||||||
|
这三类数据由 `scripts/generate-canvas-icons.mjs` 同批生成,中文标注来自 `scripts/canvas-icon-labels.mjs`。文件名摘要是紧凑 JSON 内容的 SHA-256 前 12 位,写盘时另加换行;它与分桶散列用途不同。分片变更须同步目录/manifest 并更新摘要名;脚本不会自动删除旧分片。不要在 JSON 中插入注释或只手改其中一份,否则会破坏读取、引用或缓存命名约定。
|
||||||
|
|
||||||
|
### 协议样例与忽略规则
|
||||||
|
|
||||||
|
`tests/headless/fixtures/foundry-model-operation-v2.json` 是服务适配器单测的完整请求样例。协议 2 的 `request_id` 标识冻结请求,`expected_versions` 是参与 canvas 的已确认版本,`operations[].id` 是逻辑操作 ID。样例把第一代元素 `a` 的 `data.title` 从 `a` 改为 `renamed`;`beforePresent`/`afterPresent` 区分字段缺失与有值。字段旁注与断言在 `tests/headless/service.test.mjs`,静态样例不代表真实服务已执行。
|
||||||
|
|
||||||
|
`.gitignore` 排除依赖、Next/headless 产物、环境变量、增量缓存和系统文件;仅放行 `.env.example` 作为模板。根 `/docs/` 当前作为本地研究目录忽略。忽略规则只影响未跟踪文件,不会把已经跟踪的文件自动移出 Git。`.env.example` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 独立SDK的第二宿主示例与可执行断言:用自定义counter组件串起命令、事务拒绝、历史、手势、深复制、重开及投影命中。
|
||||||
|
* 包导入只使用core/runtime/bindings/service,验证这条路径不需要React、DOM或Next;示例需在已构建SDK包的解析环境执行。
|
||||||
|
* 存储与协议transport都是进程内模型,用于演示port契约,不实现生产数据库、身份鉴权、跨进程锁或崩溃持久化。
|
||||||
|
*/
|
||||||
/** A second host: no DOM, React, Next, React Flow or network service required.
|
/** A second host: no DOM, React, Next, React Flow or network service required.
|
||||||
* Storage and protocol-2 transport below are in-memory examples, not a disk or
|
* Storage and protocol-2 transport below are in-memory examples, not a disk or
|
||||||
* production server implementation. Replace them with your host's real ports.
|
* production server implementation. Replace them with your host's real ports.
|
||||||
@@ -9,15 +14,25 @@ import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
|
|||||||
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
|
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
|
||||||
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
|
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
|
||||||
|
|
||||||
|
// 示例固定账户/工作区/项目作用域;真实宿主应从已验证身份和项目上下文建立隔离范围。
|
||||||
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
|
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
|
||||||
|
// 内置组件之外注册counter:count要求非负安全整数,能力与矩形几何显式声明,资源与复制引用列表为空。
|
||||||
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
|
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
|
||||||
|
// 空项目只有根canvas;server/version模拟服务端基线,image模拟本地恢复镜像,两者职责分开。
|
||||||
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
||||||
let image=null,leader,fence=0,server=original,version=1;
|
let image=null,leader,fence=0,server=original,version=1;
|
||||||
|
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
|
||||||
const files=new Map(),requests=new Map();
|
const files=new Map(),requests=new Map();
|
||||||
|
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
|
||||||
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
||||||
|
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
|
||||||
|
// 文件值直接保存传入 bytes 引用;示例要求宿主提供不可变字节载荷,不演示流式文件读写或增量日志。
|
||||||
const storage={
|
const storage={
|
||||||
|
/** 只允许一个活跃leader并递增fence;release幂等失效该句柄,不清除已提交镜像。 */
|
||||||
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
|
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
|
||||||
|
/** 返回镜像克隆,调用方不能直接修改存储事实;空image代表首次打开。 */
|
||||||
async recover(handle){check(handle);return structuredClone(image);},
|
async recover(handle){check(handle);return structuredClone(image);},
|
||||||
|
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
|
||||||
async commit(handle,batch){
|
async commit(handle,batch){
|
||||||
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
|
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
|
||||||
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
|
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
|
||||||
@@ -25,11 +40,15 @@ const storage={
|
|||||||
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
|
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
|
||||||
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
|
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
|
||||||
},
|
},
|
||||||
|
/** 文件读取也校验leader;close断言所有engine已释放领导权,不负责清理生产存储。 */
|
||||||
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
||||||
};
|
};
|
||||||
|
/** 将模拟server的Map转成协议模型数组,带当前版本与墓碑;不把整个engine状态冒充服务端模型。 */
|
||||||
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
|
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
|
||||||
|
/** 最小协议2传输适配:模型GET、请求回执GET及操作POST;未实现任意Foundry端点或HTTP鉴权。 */
|
||||||
const transport=async input=>{
|
const transport=async input=>{
|
||||||
input.signal?.throwIfAborted();
|
input.signal?.throwIfAborted();
|
||||||
|
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
|
||||||
if(input.method==='GET'){
|
if(input.method==='GET'){
|
||||||
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
|
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
|
||||||
const id=input.path.split('/').at(-1),known=requests.get(id);
|
const id=input.path.split('/').at(-1),known=requests.get(id);
|
||||||
@@ -37,32 +56,47 @@ const transport=async input=>{
|
|||||||
}
|
}
|
||||||
const body=JSON.parse(input.body),known=requests.get(input.requestId);
|
const body=JSON.parse(input.body),known=requests.get(input.requestId);
|
||||||
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
|
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
|
||||||
|
// 已有requestId只能复用完全相同的body并返回原回执,防止幂等键承载两种意图。
|
||||||
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
||||||
|
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
|
||||||
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
||||||
|
// server 赋值在 validate 之前,断言失败不会恢复旧 server;此替身只用于下方合法协议路径,不是生产事务实现。
|
||||||
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
||||||
|
// 回执逐一覆盖operations逻辑ID,并携complete=false的模型切片;记录原body方便重试校验。
|
||||||
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
|
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
|
||||||
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
|
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
|
||||||
};
|
};
|
||||||
|
// service把transport包成runtime持久化接口;open示例总以初始模型输入,由StoragePort恢复已提交本地状态。
|
||||||
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
||||||
|
/** 新建engine注入scope/registry/storage/persistence与ID生成器,所有宿主依赖显式传入。 */
|
||||||
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
||||||
let engine=await open();
|
let engine=await open();
|
||||||
try{
|
try{
|
||||||
|
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
|
||||||
const target={id:randomUUID(),incarnation:1};
|
const target={id:randomUUID(),incarnation:1};
|
||||||
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
|
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
|
||||||
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
|
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
|
||||||
|
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
|
||||||
const before=engine.getSnapshot();
|
const before=engine.getSnapshot();
|
||||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
||||||
|
// 合法事务将移动和增量组合成一个历史单元;undo/redo之后均等待同步,再检查server的count。
|
||||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
|
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
|
||||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
|
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
|
||||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
|
||||||
|
// 临时手势的有效几何可变化,但cancel后文档仍x=32且请求数不增加,证明预览未被当持久编辑发送。
|
||||||
engine.sessions.attach('headless-view');const writes=requests.size;
|
engine.sessions.attach('headless-view');const writes=requests.size;
|
||||||
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
|
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
|
||||||
|
// 深复制走imports任务并等待settle,检查任务完成及本地/模拟服务端元素数;随后验证service模型可读。
|
||||||
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
|
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
|
||||||
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
|
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
|
||||||
|
// 关闭再打开依赖image恢复内容、历史和ledger;视图属于临时session,重开后不恢复。
|
||||||
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
|
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
|
||||||
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
|
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
|
||||||
|
// 自定义renderer只声明记录投影,不引入UI组件;配查询与几何缓存生成场景,再验证纯数据命中结果。
|
||||||
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
|
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
|
||||||
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
|
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
|
||||||
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
|
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
|
||||||
|
// 输出可机器读取的结果摘要;finally即使断言失败也关闭engine并验证存储领导权已释放。
|
||||||
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
|
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
|
||||||
|
// 顺序等待 close 后再检查 Storage;若 close 本身拒绝,storage.close 不会执行,错误向脚本调用者传播。
|
||||||
}finally{await engine.close();storage.close();}
|
}finally{await engine.close();storage.close();}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Next应用配置。为webpack与Turbopack安装相同ReactFlow编译扩展,并给带内容摘要的图标分片设置长期缓存。
|
||||||
|
* 此处只配置应用编译/响应头,不改变headless SDK导出或服务端数据协议。
|
||||||
|
*/
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
/** StrictMode显式开启开发检查,poweredByHeader关闭Next标识响应头;其余保持框架默认行为。 */
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
reactStrictMode: true, poweredByHeader: false,
|
reactStrictMode: true, poweredByHeader: false,
|
||||||
|
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
// 规则只按路径匹配,不按客户端/服务端分支;loader 自身再检查安装版本和四段源码锚点。
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
|
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
|
||||||
use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }],
|
use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }],
|
||||||
});
|
});
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
// Turbopack使用条件路径匹配同一个入口;单独配置以免切换构建器时丢失保留节点扩展。
|
||||||
turbopack: {
|
turbopack: {
|
||||||
rules: {
|
rules: {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -16,6 +24,9 @@ const config: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
|
||||||
|
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
|
||||||
|
// 这里只匹配 URL 外观,不重新读取文件验证摘要;重生成文件名和旧资源保留由资源脚本/发布流程负责。
|
||||||
async headers() {
|
async headers() {
|
||||||
return [{
|
return [{
|
||||||
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
|
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* CSS构建入口:启用项目安装的Tailwind PostCSS插件,供Next样式处理链读取。
|
||||||
|
* 插件选项为空对象表示使用默认配置;这里不执行CSS生成,也不在导入时启动构建。
|
||||||
|
*/
|
||||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 独立headless SDK的产物生成脚本。先清理.headless-dist,再用tsconfig.headless.json编译四个库目录,修正声明与Worker引用。
|
||||||
|
* 最后复制包清单、无UI宿主示例和验收文档;失败立即抛出,不把半成品报告成可用包。执行会删除并重建该产物目录。
|
||||||
|
*/
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
// 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。
|
||||||
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
|
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
|
||||||
|
// 原产物先删除,后续任一步失败不会恢复上一版;调用方须通过脚本成功退出和包验收后才采用目录。
|
||||||
await rm(output,{recursive:true,force:true});
|
await rm(output,{recursive:true,force:true});
|
||||||
|
// 同步等待TypeScript生成ESM及声明;继承终端输出,编译失败直接中止后续处理。
|
||||||
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
|
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
|
||||||
|
/** 递归只处理.d.ts的相对模块说明符,将源.ts后缀改为.js;普通schema/data字符串不做全局替换。
|
||||||
|
* 这是针对当前输出格式的正则修补,不是任意TypeScript语法的完整重写器。 */
|
||||||
async function declarations(directory){
|
async function declarations(directory){
|
||||||
for(const entry of await readdir(directory,{withFileTypes:true})){
|
for(const entry of await readdir(directory,{withFileTypes:true})){
|
||||||
const path=join(directory,entry.name);
|
const path=join(directory,entry.name);
|
||||||
@@ -19,12 +28,16 @@ async function declarations(directory){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
|
||||||
|
// 后处理逐文件直接写入,未使用临时目录整体 rename;中途错误可留下部分已改写的输出。
|
||||||
await declarations(join(output,'modules'));
|
await declarations(join(output,'modules'));
|
||||||
|
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
|
||||||
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
|
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
|
||||||
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
|
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
|
||||||
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
|
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
|
||||||
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
|
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
|
||||||
await writeFile(path,source.replace(before,`./${worker}.js`));
|
await writeFile(path,source.replace(before,`./${worker}.js`));
|
||||||
}
|
}
|
||||||
|
// 产物保留与headless/package.json的exports/files配套的模块布局;这里复制清单,不执行npm发布或包验收测试。
|
||||||
for(const name of ['package.json','consumer.mjs','README.md','STEP4_ACCEPTANCE.md','SERVICE_ACCEPTANCE.md','FINAL_ACCEPTANCE.md'])await cp(join(root,'headless',name),join(output,name));
|
for(const name of ['package.json','consumer.mjs','README.md','STEP4_ACCEPTANCE.md','SERVICE_ACCEPTANCE.md','FINAL_ACCEPTANCE.md'])await cp(join(root,'headless',name),join(output,name));
|
||||||
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
|
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。
|
||||||
|
*/
|
||||||
|
/** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */
|
||||||
|
// 两张表都是共享的普通对象,const 不冻结字段;生成器只读取,修改映射后需显式重新生成目录。
|
||||||
export const chinese = {
|
export const chinese = {
|
||||||
"Star": "星星",
|
"Star": "星星",
|
||||||
"Heart": "爱心",
|
"Heart": "爱心",
|
||||||
@@ -45,4 +50,5 @@ export const chinese = {
|
|||||||
"Triangle": "三角形",
|
"Triangle": "三角形",
|
||||||
"Diamond": "菱形"
|
"Diamond": "菱形"
|
||||||
};
|
};
|
||||||
|
/** slug单词到中文检索词,生成时按连字符拆分并拼接;一个词可扩展多个搜索同义词。 */
|
||||||
export const keywords = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
|
export const keywords = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* 重建画布图标目录与路径分片的离线资源脚本。使用已安装Lucide与指定本地Tabler包,写目录JSON、摘要文件名分片和许可证。
|
||||||
|
* 版本检查固定输入格式;不会联网下载包,也不删除旧摘要分片,调用者需按脚本注释准备输入并从仓库根执行。
|
||||||
|
*/
|
||||||
// Reproduce with installed lucide-react 1.69.0 and an extracted @tabler/icons 3.50.0
|
// Reproduce with installed lucide-react 1.69.0 and an extracted @tabler/icons 3.50.0
|
||||||
// tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package
|
// tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package
|
||||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
@@ -6,13 +10,16 @@ import { resolve } from "node:path";
|
|||||||
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
import dynamicIconImports from "lucide-react/dynamicIconImports";
|
||||||
import { chinese, keywords } from "./canvas-icon-labels.mjs";
|
import { chinese, keywords } from "./canvas-icon-labels.mjs";
|
||||||
|
|
||||||
|
// 命令行参数是已解压Tabler包目录;同时校验两套依赖版本,防止升级后未经审查改变SVG/导出结构。
|
||||||
const tablerDir = process.argv[2];
|
const tablerDir = process.argv[2];
|
||||||
if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory");
|
if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory");
|
||||||
const tablerPackage = JSON.parse(await readFile(resolve(tablerDir, "package.json"), "utf8"));
|
const tablerPackage = JSON.parse(await readFile(resolve(tablerDir, "package.json"), "utf8"));
|
||||||
if (tablerPackage.version !== "3.50.0") throw new Error("Expected @tabler/icons 3.50.0");
|
if (tablerPackage.version !== "3.50.0") throw new Error("Expected @tabler/icons 3.50.0");
|
||||||
const lucidePackage = JSON.parse(await readFile("node_modules/lucide-react/package.json", "utf8"));
|
const lucidePackage = JSON.parse(await readFile("node_modules/lucide-react/package.json", "utf8"));
|
||||||
if (lucidePackage.version !== "1.69.0") throw new Error("Review generated assets when upgrading Lucide");
|
if (lucidePackage.version !== "1.69.0") throw new Error("Review generated assets when upgrading Lucide");
|
||||||
|
/** slug按连字符转PascalCase;首字符非字母时加Icon前缀,形成稳定的目录ID。 */
|
||||||
const pascal = (slug) => { const value = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(""); return /^[A-Z]/.test(value) ? value : "Icon" + value; };
|
const pascal = (slug) => { const value = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(""); return /^[A-Z]/.test(value) ? value : "Icon" + value; };
|
||||||
|
/** 用slug正则派生分类,一个图标可匹配多个;未匹配的记录categories为空,UI可归“其他”。 */
|
||||||
const categories = [
|
const categories = [
|
||||||
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
|
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
|
||||||
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/],
|
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/],
|
||||||
@@ -24,17 +31,22 @@ const categories = [
|
|||||||
["travel", /map|pin|compass|globe|plane|train|car|bus|bike|ship|boat|building|house|home|tent|hotel|lighthouse|rocket|ufo/],
|
["travel", /map|pin|compass|globe|plane|train|car|bus|bike|ship|boat|building|house|home|tent|hotel|lighthouse|rocket|ufo/],
|
||||||
["shapes", /circle|square|triangle|hexagon|pentagon|octagon|diamond|star|shape|cube|sphere|box/],
|
["shapes", /circle|square|triangle|hexagon|pentagon|octagon|diamond|star|shape|cube|sphere|box/],
|
||||||
];
|
];
|
||||||
|
/** Lucide常用集合用于默认分类;Tabler试用集稍后全部标记common。 */
|
||||||
const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]);
|
const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]);
|
||||||
|
// Map以唯一name聚合元数据和SVG节点,Lucide加载器的__iconData提供路径,尚未在此写最终分片。
|
||||||
|
// Map 以 PascalCase 名称去重,同名后项覆盖;不单独报告名称转换碰撞,输入包格式须按固定版本审查。
|
||||||
const entries = new Map();
|
const entries = new Map();
|
||||||
for (const [slug, load] of Object.entries(dynamicIconImports)) {
|
for (const [slug, load] of Object.entries(dynamicIconImports)) {
|
||||||
const name = pascal(slug);
|
const name = pascal(slug);
|
||||||
const { __iconData } = await load();
|
const { __iconData } = await load();
|
||||||
entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node });
|
entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node });
|
||||||
}
|
}
|
||||||
|
// Tabler只引入这24个outline图标,labels按同一索引配对;名称加Tabler前缀避免与Lucide冲突。
|
||||||
const tabler = ["alien", "ufo", "robot", "brain", "circuit-cell", "cube-3d-sphere", "hexagon-3d", "building-lighthouse", "cat", "dog", "fish", "leaf", "sun", "moon", "rocket", "bulb", "heart", "star", "photo", "movie", "music", "palette", "coffee", "mountain"];
|
const tabler = ["alien", "ufo", "robot", "brain", "circuit-cell", "cube-3d-sphere", "hexagon-3d", "building-lighthouse", "cat", "dog", "fish", "leaf", "sun", "moon", "rocket", "bulb", "heart", "star", "photo", "movie", "music", "palette", "coffee", "mountain"];
|
||||||
const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
|
const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
|
||||||
for (const [index, slug] of tabler.entries()) {
|
for (const [index, slug] of tabler.entries()) {
|
||||||
const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8");
|
const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8");
|
||||||
|
// 仅提取已知SVG图形标签与双引号属性,把连字符属性转为React驼峰;不是任意SVG/HTML的通用解析器。
|
||||||
const node = [...svg.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\s+([^>]+?)\/?\s*>/g)].map((match, index) => [match[1], Object.fromEntries([
|
const node = [...svg.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\s+([^>]+?)\/?\s*>/g)].map((match, index) => [match[1], Object.fromEntries([
|
||||||
["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]),
|
["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]),
|
||||||
])]);
|
])]);
|
||||||
@@ -42,19 +54,26 @@ for (const [index, slug] of tabler.entries()) {
|
|||||||
const name = "Tabler" + pascal(slug);
|
const name = "Tabler" + pascal(slug);
|
||||||
entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node });
|
entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node });
|
||||||
}
|
}
|
||||||
|
// 名称排序固定目录与后续分片插入顺序;相同输入有助于复现内容摘要。
|
||||||
const records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en"));
|
const records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en"));
|
||||||
const shards = new Map();
|
const shards = new Map();
|
||||||
|
/** 简单32位滚动hash模96,把Lucide名称稳定分桶;不是安全摘要,文件内容防缓存混淆另用SHA-256。 */
|
||||||
const bucket = (name) => { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 96; };
|
const bucket = (name) => { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 96; };
|
||||||
|
// 目录去掉SVG node,仅保留检索元数据;路径放入分片,Lucide分96桶,Tabler集中一个桶。
|
||||||
const catalog = records.map(({ node, ...entry }) => {
|
const catalog = records.map(({ node, ...entry }) => {
|
||||||
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
|
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
|
||||||
if (!shards.has(key)) shards.set(key, {});
|
if (!shards.has(key)) shards.set(key, {});
|
||||||
shards.get(key)[entry.name] = node;
|
shards.get(key)[entry.name] = node;
|
||||||
return { ...entry, categories: categories.filter(([, regex]) => regex.test(entry.slug)).map(([id]) => id), common: common.has(entry.name) || entry.source === "tabler", keywords: entry.slug.split("-").map((word) => keywords[word] || "").join(" ") };
|
return { ...entry, categories: categories.filter(([, regex]) => regex.test(entry.slug)).map(([id]) => id), common: common.has(entry.name) || entry.source === "tabler", keywords: entry.slug.split("-").map((word) => keywords[word] || "").join(" ") };
|
||||||
});
|
});
|
||||||
|
// 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。
|
||||||
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
|
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
|
||||||
|
// 输出在仓库当前工作目录逐文件写入,不是整体原子替换;失败时可能保留新分片和旧 manifest 的组合。
|
||||||
await mkdir(assetDir, { recursive: true });
|
await mkdir(assetDir, { recursive: true });
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
|
// 文件名摘要用于资源版本命名,不把截断的12位摘要当作完整校验/授权凭据;消费者按 manifest 定位。
|
||||||
const assets = {};
|
const assets = {};
|
||||||
|
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
|
||||||
for (const [key, nodes] of shards) {
|
for (const [key, nodes] of shards) {
|
||||||
const content = JSON.stringify(nodes);
|
const content = JSON.stringify(nodes);
|
||||||
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
||||||
@@ -63,8 +82,13 @@ for (const [key, nodes] of shards) {
|
|||||||
await writeFile(`${assetDir}/${filename}`, content + "\n");
|
await writeFile(`${assetDir}/${filename}`, content + "\n");
|
||||||
totalBytes += Buffer.byteLength(content);
|
totalBytes += Buffer.byteLength(content);
|
||||||
}
|
}
|
||||||
|
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
|
||||||
|
// catalog 每项是 name/slug/source/label/categories/common/keywords;不带 SVG node,搜索只读取这些元数据。
|
||||||
|
// manifest.root 为公开 URL 前缀,assets 将 lucide-N 或 tabler 逻辑桶映射到带内容摘要的 JSON 文件名。
|
||||||
|
// 分片对象按图标 name 索引 [tag, props][];props 为可序列化 SVG 属性,消费端还会执行标签/属性校验。
|
||||||
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
|
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
|
||||||
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
||||||
|
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
|
||||||
await writeFile(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE"));
|
await writeFile(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE"));
|
||||||
await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "LICENSE")));
|
await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "LICENSE")));
|
||||||
console.log(`${records.length} icons (${records.filter((item) => item.source === "tabler").length} Tabler), ${shards.size} data shards, ${totalBytes} uncompressed bytes`);
|
console.log(`${records.length} icons (${records.filter((item) => item.source === "tabler").length} Tabler), ${shards.size} data shards, ${totalBytes} uncompressed bytes`);
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 应用编译期的ReactFlow可见性扩展。为视口外但仍有焦点/手势/浮层的显式保留对象扩展原生可见集合。
|
||||||
|
* 只转换匹配到的编译输入字符串,不修改共享node_modules文件,也不进入独立headless SDK的运行逻辑。
|
||||||
|
*/
|
||||||
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
|
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
|
||||||
// transforms compiler input; shared node_modules and the headless SDK are untouched.
|
// transforms compiler input; shared node_modules and the headless SDK are untouched.
|
||||||
|
// 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
|
||||||
|
// require 在 loader 模块求值时读取一次版本,不在每个输入转换时重新扫描安装目录。
|
||||||
const version = require('@xyflow/react/package.json').version;
|
const version = require('@xyflow/react/package.json').version;
|
||||||
|
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
|
||||||
module.exports = function retainVisibility(source) {
|
module.exports = function retainVisibility(source) {
|
||||||
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
|
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
|
||||||
|
// 四个精确源码锚点覆盖节点查询、边可见性及选择订阅;依赖上游编译格式,不是通用AST补丁。
|
||||||
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
|
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
|
||||||
const edges = 'isEdgeVisible({\n sourceNode,';
|
const edges = 'isEdgeVisible({\n sourceNode,';
|
||||||
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
|
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
|
||||||
const close = 'transform: s.transform,\n })) {';
|
const close = 'transform: s.transform,\n })) {';
|
||||||
|
// 每个锚点只能出现一次,并拒绝重复处理已含标记的输入;避免替换错位置或把运算括号重复扩展。
|
||||||
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) {
|
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) {
|
||||||
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
|
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
|
||||||
}
|
}
|
||||||
|
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
|
||||||
|
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
|
||||||
|
// 返回新字符串,不写磁盘;锚点检查只确认当前四段文本的形状,不证明升级后所有语义仍等价。
|
||||||
return source
|
return source
|
||||||
.replace(nodes, `Array.from(new Set([
|
.replace(nodes, `Array.from(new Set([
|
||||||
...${nodes},
|
...${nodes},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
|
|||||||
}
|
}
|
||||||
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
|
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
|
||||||
if (!size) return binary ? new Uint8Array() : {};
|
if (!size) return binary ? new Uint8Array() : {};
|
||||||
|
// chunks 与合并 Buffer 在此可能同时占内存;limit 约束载荷字节,不是整个请求处理的堆内存上限。
|
||||||
const bytes = Buffer.concat(chunks);
|
const bytes = Buffer.concat(chunks);
|
||||||
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
|
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ async function handler(
|
|||||||
return json({ error: "invalid_origin" }, 403);
|
return json({ error: "invalid_origin" }, 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Origin 比对只使用 host(含端口),不要求 scheme 相同;也不在代理本地解析会话账户权限。
|
||||||
const path = (await params).path.join("/");
|
const path = (await params).path.join("/");
|
||||||
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
|
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
|
||||||
// operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。
|
// operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。
|
||||||
@@ -75,6 +77,7 @@ async function handler(
|
|||||||
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
|
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
|
||||||
const canvas = canvasRoute.test(path);
|
const canvas = canvasRoute.test(path);
|
||||||
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
|
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
|
||||||
|
// frozenAI 仅匹配 ai/runs 分支,ai/operations 查询不由此标记;新文档 model 路径整体由 canonical 覆盖。
|
||||||
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
|
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
|
||||||
let requestBody: unknown;
|
let requestBody: unknown;
|
||||||
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
|
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
|
||||||
@@ -108,6 +111,7 @@ async function handler(
|
|||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) {
|
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) {
|
||||||
const values = req.nextUrl.searchParams.getAll(key);
|
const values = req.nextUrl.searchParams.getAll(key);
|
||||||
|
// 不合规则的已知参数和名单外参数都静默不转发,不在这里返回 invalid_request。
|
||||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||||
}
|
}
|
||||||
const encodingStarted = performance.now();
|
const encodingStarted = performance.now();
|
||||||
@@ -119,6 +123,7 @@ async function handler(
|
|||||||
try {
|
try {
|
||||||
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
|
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
|
||||||
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
|
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
|
||||||
|
// canonical GET 包括操作回执查询;只有这些 GET 使用分层流超时,写入仍用下方15秒总时限。
|
||||||
const streamedRead = snapshotRead || asset && req.method === "GET";
|
const streamedRead = snapshotRead || asset && req.method === "GET";
|
||||||
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
|
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
|
||||||
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
|
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
|
||||||
@@ -151,12 +156,14 @@ async function handler(
|
|||||||
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
||||||
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
|
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
|
||||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||||
|
// 纯数字也可能超出安全整数,sessionMaxAge 此时回退默认30天;续期不代表这里验证了账户资料。
|
||||||
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
|
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
|
||||||
}
|
}
|
||||||
// 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。
|
// 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。
|
||||||
if (asset && upstream.ok && req.method === "GET") {
|
if (asset && upstream.ok && req.method === "GET") {
|
||||||
const type = upstream.headers.get("content-type") || "";
|
const type = upstream.headers.get("content-type") || "";
|
||||||
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); }
|
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); }
|
||||||
|
// MIME 白名单只看响应头,不解码图片或核对实际字节类型;内容权威校验属于上游资源协议。
|
||||||
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
|
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
|
||||||
const variant = upstream.headers.get("x-asset-variant");
|
const variant = upstream.headers.get("x-asset-variant");
|
||||||
if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant);
|
if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant);
|
||||||
@@ -173,6 +180,7 @@ async function handler(
|
|||||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||||
}
|
}
|
||||||
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
|
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
|
||||||
|
// 普通响应没有预解析字节上限;请求体 limit 不能用来证明上游 JSON 响应也受同样约束。
|
||||||
const body = upstream.status === 204 ? null : await upstream.json();
|
const body = upstream.status === 204 ? null : await upstream.json();
|
||||||
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
|
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
|
||||||
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
|
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
|
||||||
@@ -190,6 +198,7 @@ async function handler(
|
|||||||
);
|
);
|
||||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||||
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
|
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
|
||||||
|
// 按字段存在处理,不仅限定 auth 路径/成功状态;原始流透传分支不进入此令牌移除逻辑。
|
||||||
if (body?.access_token) {
|
if (body?.access_token) {
|
||||||
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
|
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
|
||||||
delete body.access_token;
|
delete body.access_token;
|
||||||
@@ -197,6 +206,7 @@ async function handler(
|
|||||||
const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
||||||
if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure));
|
if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure));
|
||||||
}
|
}
|
||||||
|
// 401 不在这里主动清 Cookie;错误响应保持状态,客户端再决定登录/重试流程。
|
||||||
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
|
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
|
||||||
if (path === "auth/logout" && upstream.ok)
|
if (path === "auth/logout" && upstream.ok)
|
||||||
responseHeaders.set(
|
responseHeaders.set(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { provider } = await params;
|
const { provider } = await params;
|
||||||
const challenges = req.nextUrl.searchParams.getAll("challenge");
|
const challenges = req.nextUrl.searchParams.getAll("challenge");
|
||||||
|
// 这里只验证 challenge 字符表示,不验证 verifier/登录 state 或交换身份;额外查询参数也不会透传上游。
|
||||||
const challenge = challenges[0];
|
const challenge = challenges[0];
|
||||||
if (
|
if (
|
||||||
!["google", "slack"].includes(provider) ||
|
!["google", "slack"].includes(provider) ||
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import GraphPage from "@/components/workspace/graph-page";
|
import GraphPage from "@/components/workspace/graph-page";
|
||||||
|
|
||||||
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
|
/** params按当前Next版本await;Suspense为子树渲染时的挂起提供占位,不在此发模型请求。
|
||||||
|
* params 的 await 在此边界外;GraphPage 在 effect 中加载模型时的进度/错误由自身状态呈现。 */
|
||||||
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
|
||||||
const { workspace, board } = await params;
|
const { workspace, board } = await params;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default function DashboardSidebar() {
|
|||||||
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
|
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
|
||||||
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
|
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
|
||||||
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
|
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
|
||||||
|
// 目录未解析时可暂用路由 ref;这里的字符串回退不是“空间存在/有权限”的证明,后续服务请求仍需验证。
|
||||||
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
|
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
|
||||||
|
|
||||||
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
|
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
|
||||||
@@ -68,6 +69,7 @@ export default function DashboardSidebar() {
|
|||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
// 只切换加载状态,不清空之前 profile/workspaces/error;成功读取也不会在这里自动清除旧 error。
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
loadWorkspaceHome(api, controller.signal)
|
loadWorkspaceHome(api, controller.signal)
|
||||||
.then(({ profile, workspaces }) => {
|
.then(({ profile, workspaces }) => {
|
||||||
@@ -89,6 +91,7 @@ export default function DashboardSidebar() {
|
|||||||
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
|
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspace || status !== "ready") return;
|
if (!workspace || status !== "ready") return;
|
||||||
|
// listVersion 只保护目录结果采用,不是网络取消或账号代次;创建成功时也递增它,防较旧列表覆盖新项。
|
||||||
const version = ++listVersion.current;
|
const version = ++listVersion.current;
|
||||||
let active = true;
|
let active = true;
|
||||||
setBoards((list) => list.filter((board) => board.workspace_id === workspace));
|
setBoards((list) => list.filter((board) => board.workspace_id === workspace));
|
||||||
@@ -120,6 +123,7 @@ export default function DashboardSidebar() {
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
// 捕获触发本次创建的 workspace/demo;await 后不另核对挂载/账户,宿主生命周期应避免旧操作采用到新界面。
|
||||||
const board = demo
|
const board = demo
|
||||||
? { id: crypto.randomUUID(), workspace_id: workspace, title: DEFAULT_BOARD_TITLE, kind: "board" as const, version: 1 }
|
? { id: crypto.randomUUID(), workspace_id: workspace, title: DEFAULT_BOARD_TITLE, kind: "board" as const, version: 1 }
|
||||||
: await createBoard(api, DEFAULT_BOARD_TITLE, workspace, (created) => {
|
: await createBoard(api, DEFAULT_BOARD_TITLE, workspace, (created) => {
|
||||||
@@ -128,6 +132,7 @@ export default function DashboardSidebar() {
|
|||||||
});
|
});
|
||||||
listVersion.current += 1;
|
listVersion.current += 1;
|
||||||
setBoards((list) => [board, ...list]);
|
setBoards((list) => [board, ...list]);
|
||||||
|
// demo 已先更新 React 列表,随后 Storage 写失败也不回滚该列表;错误仅报告在侧栏。
|
||||||
if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)]));
|
if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)]));
|
||||||
setQuery("");
|
setQuery("");
|
||||||
router.push(boardHref(currentWorkspace?.slug || board.workspace_id, board.slug || board.id, demo));
|
router.push(boardHref(currentWorkspace?.slug || board.workspace_id, board.slug || board.id, demo));
|
||||||
@@ -158,6 +163,7 @@ export default function DashboardSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
|
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
|
||||||
|
// 此函数无独立 busy/ref 互斥;多次点击仍可调用。退休身份成功但上游登出失败时也不会复活旧 runtime。
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
|
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
|
||||||
@@ -177,6 +183,7 @@ export default function DashboardSidebar() {
|
|||||||
|
|
||||||
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
|
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
|
||||||
async function renameTitle(title: string) {
|
async function renameTitle(title: string) {
|
||||||
|
// 优先使用 activeBoard.id;编辑按钮先约束路由/空间匹配,此函数本身不重新执行同一组匹配检查。
|
||||||
const id = activeBoard?.id || params.board;
|
const id = activeBoard?.id || params.board;
|
||||||
if (!id || !workspace) return;
|
if (!id || !workspace) return;
|
||||||
try {
|
try {
|
||||||
@@ -184,6 +191,7 @@ export default function DashboardSidebar() {
|
|||||||
const list = readDemoBoards(workspace);
|
const list = readDemoBoards(workspace);
|
||||||
localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board)));
|
localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board)));
|
||||||
} else await renameBoard(api, workspace, id, title);
|
} else await renameBoard(api, workspace, id, title);
|
||||||
|
// 目录更新按 ID 匹配,活动元数据更新额外比 workspace;此处不递增 listVersion,旧目录读取仍可能随后到达。
|
||||||
setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board));
|
setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board));
|
||||||
setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board);
|
setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board);
|
||||||
} catch (error) { throw new Error(errorText(error)); }
|
} catch (error) { throw new Error(errorText(error)); }
|
||||||
@@ -196,6 +204,7 @@ export default function DashboardSidebar() {
|
|||||||
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间";
|
const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间";
|
||||||
|
|
||||||
|
// 折叠只改变显示和键盘可达性,侧栏仍挂载,账户/目录状态和 effect 不因收起而销毁。
|
||||||
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
|
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { ActiveBoardProvider } from "@/components/workspace/active-board";
|
|||||||
import DashboardSidebar from "./dashboard";
|
import DashboardSidebar from "./dashboard";
|
||||||
import styles from "@/components/workspace/workspace.module.css";
|
import styles from "@/components/workspace/workspace.module.css";
|
||||||
|
|
||||||
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
|
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。
|
||||||
|
* 该边界响应侧栏渲染期间的 suspend,不自动等待侧栏在 effect 中发起的普通请求。 */
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ActiveBoardProvider>
|
<ActiveBoardProvider>
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,8 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
|
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。
|
||||||
|
* children 作为插槽传入客户端宿主,不会因此把所有子页面模块变成客户端代码;宿主的释放逻辑在自身模块。 */
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" data-scroll-behavior="smooth">
|
<html lang="zh-CN" data-scroll-behavior="smooth">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function Login() {
|
|||||||
verify = hash.get("verify_email"),
|
verify = hash.get("verify_email"),
|
||||||
failure = hash.get("error");
|
failure = hash.get("error");
|
||||||
if (!code && !verify && !failure) return;
|
if (!code && !verify && !failure) return;
|
||||||
|
// 不只删除认证片段,也删除原 query;当前页面没有保留/恢复任意 returnTo 的流程。
|
||||||
history.replaceState(null, "", location.pathname);
|
history.replaceState(null, "", location.pathname);
|
||||||
if (failure) {
|
if (failure) {
|
||||||
setError("第三方登录未完成,请重新尝试。");
|
setError("第三方登录未完成,请重新尝试。");
|
||||||
@@ -68,6 +69,7 @@ export default function Login() {
|
|||||||
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
|
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// busy 是显示状态,不是函数入口互斥锁;按钮禁用限制常规点击,submit 本身不检查已在执行。
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
@@ -76,6 +78,7 @@ export default function Login() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
// 使用触发本次提交时 render 闭包里的 register;等待期间切换模式不会改变这次请求的后续分支。
|
||||||
if (register)
|
if (register)
|
||||||
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
||||||
else router.push("/dashboard");
|
else router.push("/dashboard");
|
||||||
@@ -98,6 +101,7 @@ export default function Login() {
|
|||||||
setError("无法启动登录,请重新尝试。");
|
setError("无法启动登录,请重新尝试。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// submit/oauth 同样没有卸载取消或结果代次校验;回调链结束前可继续调用 router/setState。
|
||||||
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
|
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
|
||||||
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
|
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ export default async function Home() {
|
|||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
|
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
|
||||||
|
// 先保存 HTTP 成功判定;随后 cancel 若抛错,catch 不会把 authenticated 重置为 false。
|
||||||
authenticated = response.ok;
|
authenticated = response.ok;
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
// An unverified session must not hide the public landing page.
|
// An unverified session must not hide the public landing page.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// redirect 放在上方 try/catch 外,避免把框架用于导航的控制流异常当作 profile 请求失败吞掉。
|
||||||
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
|
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
|
||||||
if (authenticated) redirect("/dashboard");
|
if (authenticated) redirect("/dashboard");
|
||||||
return <LandingPage />;
|
return <LandingPage />;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { CanvasModel, useCanvasAI } from "./use-canvas-ai";
|
|||||||
import styles from "./canvas.module.css";
|
import styles from "./canvas.module.css";
|
||||||
/** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */
|
/** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */
|
||||||
export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
|
export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
|
||||||
|
// 配置只用初次挂载的 node 初始化,不随外部 data.ai 自动重置;切换目标时应由宿主重建/隔离面板。
|
||||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||||
const [model, setModel] = useState(node.data.ai?.modelId || "");
|
const [model, setModel] = useState(node.data.ai?.modelId || "");
|
||||||
const [prompt, setPrompt] = useState(node.data.ai?.prompt || "");
|
const [prompt, setPrompt] = useState(node.data.ai?.prompt || "");
|
||||||
@@ -24,6 +25,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
|
|||||||
const image = ["image", "plain-image"].includes(node.data.kind);
|
const image = ["image", "plain-image"].includes(node.data.kind);
|
||||||
const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id));
|
const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id));
|
||||||
const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
|
const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
|
||||||
|
// starting 是 React 状态,按钮禁用在重渲染后生效,不是函数入口的同步互斥标记。
|
||||||
// starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。
|
// starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。
|
||||||
const busy = starting || step?.status === "queued" || step?.status === "running";
|
const busy = starting || step?.status === "queued" || step?.status === "running";
|
||||||
// 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。
|
// 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。
|
||||||
@@ -39,6 +41,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [workspace, boardId, image, ai.live]);
|
}, [workspace, boardId, image, ai.live]);
|
||||||
/** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */
|
/** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */
|
||||||
|
// 每次保存都显式 parameters:{},不会保留节点先前的自定义参数。
|
||||||
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
|
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
|
||||||
/** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */
|
/** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */
|
||||||
const persist = () => { if (model && ai.live) save(config()); };
|
const persist = () => { if (model && ai.live) save(config()); };
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest
|
|||||||
if (!request) return;
|
if (!request) return;
|
||||||
const focus = document.activeElement as HTMLElement | null;
|
const focus = document.activeElement as HTMLElement | null;
|
||||||
ref.current?.querySelector<HTMLButtonElement>("button[data-choice]")?.focus();
|
ref.current?.querySelector<HTMLButtonElement>("button[data-choice]")?.focus();
|
||||||
|
// outside 不阻止事件,关闭后原指针仍可交给被点击的画布/控件;这是非模态浮层,不提供焦点圈定。
|
||||||
const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); };
|
const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); };
|
||||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } };
|
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } };
|
||||||
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
|
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
|
||||||
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
|
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
|
||||||
}, [request, close]);
|
}, [request, close]);
|
||||||
|
// choose 不在本组件内调用 close;异步创建、异常报告和关闭时机由宿主回调负责。
|
||||||
|
// close 引用或 request 变化会重装监听并重新捕获焦点,不只在空→非空时执行。
|
||||||
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
|
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
|
||||||
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
|
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
|
||||||
if (typeof document === "undefined") return null;
|
if (typeof document === "undefined") return null;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||||
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
||||||
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
|
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
|
||||||
|
// marker 命名未加入 viewId/实例 ID,且字符替换可能碰撞;调用方须维持当前 SVG 文档内标识可用。
|
||||||
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
||||||
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
||||||
const knots = data.path?.knots || [];
|
const knots = data.path?.knots || [];
|
||||||
@@ -76,6 +77,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
||||||
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
|
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
|
||||||
const lease = editing.begin(props.id); if (lease === false) return;
|
const lease = editing.begin(props.id); if (lease === false) return;
|
||||||
|
// begin 已执行后才安装监听/取得捕获;后续动作实现应不抛错,否则这里没有总括 try/finally 回滚。
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
||||||
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
|
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
|
||||||
@@ -92,6 +94,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
if (!ownsPointerCapture(current,event)) return;
|
if (!ownsPointerCapture(current,event)) return;
|
||||||
if(!current)return;
|
if(!current)return;
|
||||||
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
// 读的是最新 render 的 path,不是 pointerdown 快照;需要父层把 preview 更新投影回来以便后续事件继续编辑。
|
||||||
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
||||||
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
|
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
|
||||||
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
||||||
@@ -119,6 +122,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||||
if(!gesture.current)return;event.stopPropagation();
|
if(!gesture.current)return;event.stopPropagation();
|
||||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
|
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
|
||||||
|
// 提交/取消动作先于 release,且未用 finally 包裹;动作抛错可能跳过捕获释放,不能称无条件清理。
|
||||||
if (current.lease) current.lease.commit(); else editing.finish?.();
|
if (current.lease) current.lease.commit(); else editing.finish?.();
|
||||||
releasePointerCapture(current.capture,current.pointerId);
|
releasePointerCapture(current.capture,current.pointerId);
|
||||||
}
|
}
|
||||||
@@ -132,6 +136,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
||||||
releasePointerCapture(current.capture,current.pointerId);
|
releasePointerCapture(current.capture,current.pointerId);
|
||||||
}
|
}
|
||||||
|
// 工具栏/键盘 patch 不是拖动 lease 流程,是否形成一次 history 单元仍由 Provider 决定。
|
||||||
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
|
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
|
||||||
pointerHandlers.current = { move, stop, cancel };
|
pointerHandlers.current = { move, stop, cancel };
|
||||||
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
|
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
|
||||||
@@ -147,6 +152,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
editing.patch(props.id, { ...data, path: next });
|
editing.patch(props.id, { ...data, path: next });
|
||||||
}
|
}
|
||||||
}} />;
|
}} />;
|
||||||
|
// selectedKnot 不随外部 knots 删除自动重置;工具栏显示模式可退到最后一点,删除回调仍优先使用旧选中 ID。
|
||||||
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
|
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
|
||||||
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
|
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
|
||||||
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
|
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
|
||||||
@@ -178,6 +184,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|||||||
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
|
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
|
||||||
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
|
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
|
||||||
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
||||||
|
// 本临时线仍使用兼容端点算法,与上方已建连线的 __headlessRecord 原生坐标分流不同。
|
||||||
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
||||||
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||||
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
|
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setResult({ name, paths: peekIcon(name) });
|
setResult({ name, paths: peekIcon(name) });
|
||||||
|
// 清理通过订阅 signal 取消,但 then 没有额外 active/代次检查;已兑现 Promise 的回调仍可能运行。
|
||||||
|
// result.name 只能隔离不同名称,同名重试之间不靠此字段区分代次。
|
||||||
loadIcon(name, controller.signal).then((paths) => setResult({ name, paths }), (error) => {
|
loadIcon(name, controller.signal).then((paths) => setResult({ name, paths }), (error) => {
|
||||||
if (error.name !== "AbortError") setResult({ name, error: true });
|
if (error.name !== "AbortError") setResult({ name, error: true });
|
||||||
});
|
});
|
||||||
@@ -27,8 +29,10 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
|
|||||||
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
|
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
|
||||||
const paths = result.name === name ? result.paths : undefined;
|
const paths = result.name === name ? result.paths : undefined;
|
||||||
const failed = result.name === name && result.error;
|
const failed = result.name === name && result.error;
|
||||||
|
// retry 不清浏览器 HTTP 缓存;同名已有成功分片时,loadIcon 仍可能直接复用共享缓存。
|
||||||
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
|
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
|
||||||
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
|
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
|
||||||
|
// props 在默认 SVG 属性之后展开,可覆盖 viewBox 等;data-icon-* 和最终 onClick 在其后固定。
|
||||||
// 加载中用虚线方框,失败用叉号与title,data-icon-state供样式或检查观察显示状态。
|
// 加载中用虚线方框,失败用叉号与title,data-icon-state供样式或检查观察显示状态。
|
||||||
return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={width} height={height} fill="none" stroke="currentColor" strokeWidth={absoluteStrokeWidth && typeof size === "number" ? Number(strokeWidth) * 24 / size : strokeWidth} strokeLinecap="round" strokeLinejoin="round"
|
return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={width} height={height} fill="none" stroke="currentColor" strokeWidth={absoluteStrokeWidth && typeof size === "number" ? Number(strokeWidth) * 24 / size : strokeWidth} strokeLinecap="round" strokeLinejoin="round"
|
||||||
{...props} data-icon-id={name} data-icon-state={paths ? "loaded" : failed ? "error" : "loading"}
|
{...props} data-icon-id={name} data-icon-state={paths ? "loaded" : failed ? "error" : "loading"}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded
|
|||||||
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
|
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
|
||||||
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
|
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
|
||||||
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
|
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
|
||||||
|
// onLoad 只携带元素 id,不附带 src/资源代次;迟到加载能否清掉当前预览需由 Provider 的 loaded 实现判定。
|
||||||
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
|
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
|
||||||
|
// 没有 onError 状态或自动重试;底图失败时若上层仍保留 local,覆盖图可继续显示。
|
||||||
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
|
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
|
||||||
if (!local || local === src) return remote;
|
if (!local || local === src) return remote;
|
||||||
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
|
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function useCanvasOverlay(targetId:string,placement:string){
|
|||||||
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
||||||
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
|
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
|
||||||
useViewport();
|
useViewport();
|
||||||
|
// 此处 getSnapshot 只读取当前 render;不单独订阅元素代次,外层须在元素变化时让节点重新渲染。
|
||||||
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
|
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
|
||||||
// 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。
|
// 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。
|
||||||
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
|
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
|
||||||
@@ -26,10 +27,12 @@ export function useCanvasOverlay(targetId:string,placement:string){
|
|||||||
// 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。
|
// 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。
|
||||||
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
|
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
|
||||||
// focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。
|
// focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。
|
||||||
|
// 已排队的 blur 微任务无法取消,清理后仍会测量;索引租约的 token 检查使旧 update 不再发布。
|
||||||
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
|
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
|
||||||
// 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。
|
// 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。
|
||||||
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
|
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
|
||||||
},[scope,instance,targetId,incarnation,placement]);
|
},[scope,instance,targetId,incarnation,placement]);
|
||||||
|
// ResizeObserver 只观察尺寸,普通页面滚动/位置动画不一定触发它;边界还依赖视口重渲染与布局提交刷新。
|
||||||
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
|
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
|
||||||
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImage
|
|||||||
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
|
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
|
||||||
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
|
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
|
||||||
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
|
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
|
||||||
|
// 模块级初始化一次,供多个宿主复用;preview 选择同一组件不代表自动只读,交互权限仍由外层上下文限制。
|
||||||
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
|
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
|
|||||||
element?.setSelectionRange(element.value.length, element.value.length);
|
element?.setSelectionRange(element.value.length, element.value.length);
|
||||||
}, [editing]);
|
}, [editing]);
|
||||||
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
|
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
|
||||||
|
// begin 没有成功返回值约定;即使作用域包装静默拒绝动作,本地 editing 仍被设 true,文档修改仍由动作护栏判定。
|
||||||
function begin() { actions.begin(id); setEditing(true); }
|
function begin() { actions.begin(id); setEditing(true); }
|
||||||
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
|
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
|
||||||
const active = editing && selected;
|
const active = editing && selected;
|
||||||
const label = plain ? "纯文本" : data.title;
|
const label = plain ? "纯文本" : data.title;
|
||||||
|
// 本组件未监听 compositionstart/end;正文变化直接转交 edit,输入法合并与 history 分组由 Provider 决定。
|
||||||
|
// textarea 的 Escape 处理也不检查 isComposing,仅用于退出输入态。
|
||||||
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
|
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
|
||||||
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
|
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
|
||||||
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
|
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
|
||||||
|
|||||||
@@ -1,91 +1,124 @@
|
|||||||
|
/* 画布与节点的局部样式:React Flow 全局类通过 :global 组合,状态类由组件/手势控制器切换;CSS 只控制呈现和命中,不负责数据归属、提交或锁。 */
|
||||||
|
/* 画布铺满定位父容器;随后覆盖 React Flow 背景、选中边框和 attribution 外观。 */
|
||||||
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
|
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
|
||||||
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
|
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
|
||||||
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
|
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
|
||||||
|
/* 手形模式在 pane/节点上给 grab 光标,拖动 pane 时改为 grabbing。 */
|
||||||
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
|
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
|
||||||
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
|
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
|
||||||
|
/* 卡片基础边框/圆角/阴影,selectedNode 单独标记选择;标题绝对放在节点上方。 */
|
||||||
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
|
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
|
||||||
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
|
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
|
||||||
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
|
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
|
||||||
|
/* 文本填满 flex 容器,允许尺寸缩到零且关闭原生 resize;焦点时覆盖全局输入阴影,避免额外边框。 */
|
||||||
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
|
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
|
||||||
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
|
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
|
||||||
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
|
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
|
||||||
|
/* 卡片图片保持原比例装入可用空间,不以图片固有尺寸撑开节点。 */
|
||||||
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
|
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
|
||||||
|
/* 工具栏按 aria-pressed 高亮当前模式;disabled 光标不表示实际业务锁逻辑。 */
|
||||||
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||||
.toolbar button:disabled { cursor: not-allowed; }
|
.toolbar button:disabled { cursor: not-allowed; }
|
||||||
|
/* 右上工具条纵排;缩放值固定 48px 并使用等宽数字,避免百分比变化挤动旁边按钮。 */
|
||||||
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||||
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
|
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
|
||||||
.toolbar .zoomValue:hover { background: #e9e7e3; }
|
.toolbar .zoomValue:hover { background: #e9e7e3; }
|
||||||
|
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
|
||||||
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
|
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
|
||||||
|
/* 帮助面板固定在视口右下方,有宽高上限和内部滚动,长内容不溢出视口。 */
|
||||||
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
|
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
|
||||||
.helpPopup strong { color: #49372e; font-size: 13px; }
|
.helpPopup strong { color: #49372e; font-size: 13px; }
|
||||||
.helpPopup p { margin-top: 9px; }
|
.helpPopup p { margin-top: 9px; }
|
||||||
|
/* 画布错误在上方居中显示,宽度随视口收缩,不参与节点几何计算。 */
|
||||||
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
||||||
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
|
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
|
||||||
|
/* 迁移控制面板处于较高层级;按钮状态、是否允许迁移由组件判断。 */
|
||||||
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
|
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
|
||||||
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
|
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
|
||||||
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
||||||
|
/* 旧/迁移画布表面铺满宿主,具体渲染内容由桥接组件决定。 */
|
||||||
.migrationSurface { position: absolute; inset: 0; }
|
.migrationSurface { position: absolute; inset: 0; }
|
||||||
|
/* 窄屏把右上工具条下移避让标题,底部工具条改左对齐并横向滚动,预留右侧帮助空间。 */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.topRight { top: 62px; right: 12px; }
|
.topRight { top: 62px; right: 12px; }
|
||||||
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
|
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手形模式让文本/图片不拦截指针;右上工具条自身横向滚动以容纳窄视口。 */
|
||||||
.handMode .node textarea, .handMode .node img { pointer-events: none; }
|
.handMode .node textarea, .handMode .node img { pointer-events: none; }
|
||||||
|
|
||||||
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
|
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||||
|
|
||||||
|
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
|
||||||
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
|
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
/* 变换容器以中心为 transform-origin,保留溢出给标题、handle 和提示;边框/阴影变化有短过渡。 */
|
||||||
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
|
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
|
||||||
|
/* 纯内容 frame 默认透明边框,选中才显示;plainText 后置 padding 覆盖卡片文本的 14px。 */
|
||||||
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
|
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
|
||||||
.plainFrame.selectedFrame { border-color: #a47764; }
|
.plainFrame.selectedFrame { border-color: #a47764; }
|
||||||
.plainText { padding: 4px; }
|
.plainText { padding: 4px; }
|
||||||
|
/* 中心连接点默认透明且不响应指针;选中只改变可见性,connectionindicator 才开放命中。 */
|
||||||
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
|
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
|
||||||
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
|
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||||
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
||||||
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
|
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
|
||||||
|
/* 右下缩放按钮单独命中、禁止触摸默认行为;旋转模式隐藏它,避免两种手势同时起动。 */
|
||||||
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
|
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
|
||||||
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
|
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
|
||||||
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
|
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
|
||||||
|
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
|
||||||
.rotatingFrame { cursor: grabbing; touch-action: none; }
|
.rotatingFrame { cursor: grabbing; touch-action: none; }
|
||||||
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
||||||
|
/* 角度提示放在 frame 下方,禁止命中以免遮住正在进行的手势。 */
|
||||||
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
|
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
|
||||||
|
/* 节点样式工具栏采用横排可滚动表面,按钮尺寸固定;数值/选择器共用高度。 */
|
||||||
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
|
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||||
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
|
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
|
||||||
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||||
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
|
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
|
||||||
.styleToolbar input[type="number"] { width: 48px; }
|
.styleToolbar input[type="number"] { width: 48px; }
|
||||||
|
/* 可见圆点只显示颜色,透明原生 color input 覆盖整个点击区域。 */
|
||||||
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
|
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
|
||||||
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
|
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
|
||||||
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||||
|
/* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */
|
||||||
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
|
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
|
||||||
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
|
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
|
||||||
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
|
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
|
||||||
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
|
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
|
||||||
.branchMenu button:hover { background: #f1e6df; }
|
.branchMenu button:hover { background: #f1e6df; }
|
||||||
|
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
|
||||||
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
|
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
|
||||||
.iconContent svg { pointer-events: none; }
|
.iconContent svg { pointer-events: none; }
|
||||||
|
/* 图标选择器固定在左侧,可用高度限定并采用纵向 flex,头/搜索/分类与列表分区。 */
|
||||||
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
|
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
|
||||||
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
|
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
|
||||||
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
|
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
|
||||||
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||||
|
/* 普通图标网格五列并内部滚动;后续 virtualIconGrid 是虚拟列表视口,窗口项目由 JS 定位。 */
|
||||||
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
|
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
|
||||||
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
|
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
|
||||||
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
||||||
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
|
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
|
||||||
|
/* 来源选择器限制宽度,分类按钮可换行并按 aria-pressed 显示当前筛选。 */
|
||||||
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
|
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
|
||||||
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
|
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
|
||||||
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
||||||
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
|
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
|
||||||
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||||
|
/* flex:1 让列表吃剩余高度,overscroll contain 减少滚动传到外层;CSS 不负责裁剪数据窗口。 */
|
||||||
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
|
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
|
||||||
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
|
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
|
||||||
.virtualIconGrid button:hover { background-color: #f1e6df; }
|
.virtualIconGrid button:hover { background-color: #f1e6df; }
|
||||||
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
|
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
|
||||||
|
/* 图标资源加载提示与选择器使用相近层级/起点。 */
|
||||||
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
|
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
|
||||||
|
/* 归属徽标放在节点下方,限制长名称且不参与命中;旋转时隐藏以减少遮挡。 */
|
||||||
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
||||||
.rotatingFrame .membershipBadge { display: none; }
|
.rotatingFrame .membershipBadge { display: none; }
|
||||||
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
|
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
|
||||||
|
/* 名称模式按钮宽度随文字,菜单固定定位且限制在视口内,勾选列保持对齐。 */
|
||||||
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
|
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
|
||||||
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
|
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
|
||||||
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
|
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
|
||||||
@@ -94,33 +127,43 @@
|
|||||||
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
|
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
|
||||||
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
|
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
|
||||||
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
|
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
|
||||||
|
/* 加入目标用绿色边框/光晕,移出预览改虚线;提示元素本身不拦截拖放。 */
|
||||||
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
|
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
|
||||||
.frameNode.frameDropExit { border-style: dashed; }
|
.frameNode.frameDropExit { border-style: dashed; }
|
||||||
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
|
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
|
||||||
|
/* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */
|
||||||
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
|
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
|
||||||
.membershipNotice:empty { display: none; }
|
.membershipNotice:empty { display: none; }
|
||||||
|
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
|
||||||
.handMode .plainText { pointer-events: none; }
|
.handMode .plainText { pointer-events: none; }
|
||||||
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
||||||
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
|
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
|
||||||
.handMode .styleToolbar { display: none; }
|
.handMode .styleToolbar { display: none; }
|
||||||
|
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
|
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 分区主体 pointer-events:none,让内部节点/画布继续接收事件;标签和四边命中条另行启用 pointer-events。 */
|
||||||
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
|
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
|
||||||
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
|
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
|
||||||
|
/* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */
|
||||||
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
|
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
|
||||||
|
/* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */
|
||||||
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
|
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
|
||||||
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
|
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
|
||||||
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
|
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
|
||||||
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
|
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
|
||||||
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
|
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
|
||||||
|
/* 分区改名输入沿用工具栏高度;手形模式下标签和命中边框也不接管事件。 */
|
||||||
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
|
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
|
||||||
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
|
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
|
||||||
|
/* 同步错误提示在顶部居中并允许按钮换行,说明文字独占一行。 */
|
||||||
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
|
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
|
||||||
.syncError span { flex-basis: 100%; }
|
.syncError span { flex-basis: 100%; }
|
||||||
|
|
||||||
|
/* 恢复对话框限制视口宽度并居中,backdrop 给遮罩色;模态焦点行为来自 dialog 而不是 CSS。 */
|
||||||
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
|
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
|
||||||
.recoveryDialog::backdrop { background: #30251e33; }
|
.recoveryDialog::backdrop { background: #30251e33; }
|
||||||
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
|
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
|
||||||
@@ -128,24 +171,32 @@
|
|||||||
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
|
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
|
||||||
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
|
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
|
||||||
|
|
||||||
|
/* 文本编辑容器允许 flex 收缩;readonly textarea 让出指针,由上层选择层处理选择/拖动。 */
|
||||||
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
|
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
|
||||||
.textEditor textarea[readonly] { pointer-events: none; }
|
.textEditor textarea[readonly] { pointer-events: none; }
|
||||||
|
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
|
||||||
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
|
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
|
||||||
.handMode .textSelectLayer { pointer-events: none; }
|
.handMode .textSelectLayer { pointer-events: none; }
|
||||||
|
|
||||||
.selectedFrame .textSelectLayer { cursor: grab; }
|
.selectedFrame .textSelectLayer { cursor: grab; }
|
||||||
.textSelectLayer:active { cursor: grabbing; }
|
.textSelectLayer:active { cursor: grabbing; }
|
||||||
|
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
|
||||||
.plainImageFrame { padding: 0; }
|
.plainImageFrame { padding: 0; }
|
||||||
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
|
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
|
||||||
|
/* 标题省略与改名输入分开;输入固定目标宽度并继承字体。 */
|
||||||
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
|
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
|
||||||
|
|
||||||
|
/* 三段图标选项用 isolation 建局部层叠,按钮在滑块上方,背景由滑块提供。 */
|
||||||
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
|
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
|
||||||
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
|
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
|
||||||
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
|
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
|
||||||
|
/* 滑块绝对定位并用 transform 做短过渡,实际位移由组件按选项设置。 */
|
||||||
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
|
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
|
||||||
|
/* 八向缩放 handle 按中心锚定,pointer-events:auto 覆盖外层让出命中的行为;手形模式再禁用。 */
|
||||||
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
|
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
|
||||||
.handMode .resizeHandle { pointer-events: none; }
|
.handMode .resizeHandle { pointer-events: none; }
|
||||||
|
/* 连线工具条可换行,数字、颜色、范围和选择器分别限宽,避免长表单撑出视口。 */
|
||||||
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
|
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
|
||||||
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||||
.edgeToolbar input[type="number"] { width: 50px; }
|
.edgeToolbar input[type="number"] { width: 50px; }
|
||||||
@@ -156,17 +207,24 @@
|
|||||||
.edgeToolbar button:hover { background: #f1e6df; }
|
.edgeToolbar button:hover { background: #f1e6df; }
|
||||||
.edgeToolbar button:disabled { opacity: .4; }
|
.edgeToolbar button:disabled { opacity: .4; }
|
||||||
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
||||||
|
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
|
||||||
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
||||||
|
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
|
||||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||||
|
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
|
||||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||||
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
||||||
|
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
||||||
|
/* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */
|
||||||
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
|
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
|
||||||
|
/* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */
|
||||||
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
|
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
|
||||||
.subgraphScale input { width: 45px; }
|
.subgraphScale input { width: 45px; }
|
||||||
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
|
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
|
||||||
.collapsedSubgraph small { font-size: 10px; }
|
.collapsedSubgraph small { font-size: 10px; }
|
||||||
|
/* 元素 AI 面板限制视口宽度;标题信息省略,提示词可纵向伸展,底部模型/传播/执行按钮横排。 */
|
||||||
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
|
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
|
||||||
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
|
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
|
||||||
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
|
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
|
||||||
@@ -176,32 +234,40 @@
|
|||||||
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
|
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
|
||||||
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
|
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
|
||||||
.aiPanel footer button:disabled { opacity: .35; }
|
.aiPanel footer button:disabled { opacity: .35; }
|
||||||
|
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
|
||||||
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
||||||
.aiPanel p[role="alert"] { color: #a25439; }
|
.aiPanel p[role="alert"] { color: #a25439; }
|
||||||
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
||||||
.aiConflict pre { white-space: pre-wrap; }
|
.aiConflict pre { white-space: pre-wrap; }
|
||||||
.aiConflict img { max-height: 100px; }
|
.aiConflict img { max-height: 100px; }
|
||||||
.aiConflict button { padding: 6px; color: #78523f; }
|
.aiConflict button { padding: 6px; color: #78523f; }
|
||||||
|
/* 对通用变换 frame 再声明中心 handle:默认不可见不可命中,选中显示、connectionindicator 开启命中。 */
|
||||||
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
|
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
|
||||||
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
|
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||||
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
||||||
|
/* 端口先定义几何,再在下一条默认隐藏/禁用;选中只显示,是否可连仍由 connectionindicator 决定。 */
|
||||||
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
|
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
|
||||||
.framePort { opacity: 0; pointer-events: none; }
|
.framePort { opacity: 0; pointer-events: none; }
|
||||||
.selectedFrame .framePort { opacity: 1; }
|
.selectedFrame .framePort { opacity: 1; }
|
||||||
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
||||||
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
||||||
|
/* busy 状态中的恢复/重试按钮仍显示明确可点表面。 */
|
||||||
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
|
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 图片上传状态浮在底部工具栏上方,宽度受容器限制,不作为节点持久数据。 */
|
||||||
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
|
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
|
||||||
|
|
||||||
|
/* 成员同步只显示紧凑状态和下划线操作按钮,不使用全画布遮罩。 */
|
||||||
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
|
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
|
||||||
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
|
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
|
/* 导入状态面板位于右上,有宽高上限和内部滚动;summary 切换详情,任务之间用边线分隔。 */
|
||||||
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
|
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
|
||||||
.importPanel summary { cursor: pointer; padding: 5px 0; }
|
.importPanel summary { cursor: pointer; padding: 5px 0; }
|
||||||
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
|
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
|
||||||
.importPanel p { margin: 5px 0; }
|
.importPanel p { margin: 5px 0; }
|
||||||
.importPanel small { color: #92877c; }
|
.importPanel small { color: #92877c; }
|
||||||
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
|
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
|
||||||
|
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
|
||||||
.importActions { display: flex; align-items: center; gap: 8px; }
|
.importActions { display: flex; align-items: center; gap: 8px; }
|
||||||
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
|
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import styles from "./canvas.module.css";
|
|||||||
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
|
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
|
||||||
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
|
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
|
||||||
export function ContentHandles({ selected }: { selected: boolean }) {
|
export function ContentHandles({ selected }: { selected: boolean }) {
|
||||||
|
// selected 只设置 isConnectableStart/aria-hidden;未显式设置结束端能力或 tabIndex,仍须遵守外层 ReactFlow 配置。
|
||||||
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function DraftRecovery({ restore, discard, canRestore = true, exportDraft
|
|||||||
const dialog = useRef<HTMLDialogElement>(null);
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
|
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
|
||||||
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
||||||
|
// 点击按钮只同步调用宿主,不自动 close,也没有 busy/重复点击互斥;宿主完成选择后负责卸载对话框。
|
||||||
// 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。
|
// 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。
|
||||||
if (typeof document === "undefined") return null;
|
if (typeof document === "undefined") return null;
|
||||||
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
|
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ import type { EdgeData } from "@/lib/canvas-v2";
|
|||||||
/** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。
|
/** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。
|
||||||
* finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */
|
* finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */
|
||||||
const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
|
const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
|
||||||
|
// 默认 active Set 与动作对象是共享引用,未冻结;消费方只能读取,不应通过修改默认对象来发布状态。
|
||||||
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
|
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
|
||||||
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import styles from "./canvas.module.css";
|
|||||||
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
||||||
const a = data.appearance || {}, animation = data.animation || {};
|
const a = data.appearance || {}, animation = data.animation || {};
|
||||||
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
|
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
|
||||||
|
// 合并基于本次 render 捕获的 data;change 必须由外层正确采用,不能把旧回调当成读取最新文档的入口。
|
||||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||||
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
||||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||||
@@ -20,6 +21,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count
|
|||||||
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
|
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
|
||||||
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
|
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
|
||||||
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
|
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
|
||||||
|
// 每个控件即时调用回调,不负责 history 分组或持久化;数值范围检查也不要求步长整数倍。
|
||||||
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
|
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
|
||||||
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
|
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
|
||||||
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
||||||
|
|
||||||
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
|
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略。
|
||||||
|
* 是否识别精确交互由 isCurrent 的实现保证;这里只冻结外壳,不复制 actions 或吞掉检查/动作异常。 */
|
||||||
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
|
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function MembershipBadge({ id, frameId, selected }: { id: string; frameId
|
|||||||
const name = target ? names.get(target) : undefined;
|
const name = target ? names.get(target) : undefined;
|
||||||
if (!drop && !selected) return null;
|
if (!drop && !selected) return null;
|
||||||
// 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。
|
// 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。
|
||||||
|
// 提示依赖 names 完整:目标有 ID 却缺名称时也会落入下面的移出/空提示分支,不证明真实归属已改变。
|
||||||
const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null;
|
const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null;
|
||||||
return label && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
|
return label && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const options = [
|
|||||||
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
|
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
|
||||||
// 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。
|
// 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。
|
||||||
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
// 未知值只让视觉索引回退0,下面 radio 的 aria-checked/tabIndex 仍按原 value 比较,不会自动修复模型。
|
||||||
const current = Math.max(0, options.findIndex((option) => option.value === value));
|
const current = Math.max(0, options.findIndex((option) => option.value === value));
|
||||||
|
// 键盘计算基于受控 value 而非实际焦点;change 应让宿主及时发布新值,连续按键才沿新索引前进。
|
||||||
// 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。
|
// 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。
|
||||||
// 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。
|
// 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。
|
||||||
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||||
|
|||||||
@@ -38,15 +38,19 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
|
|||||||
const cancelBlur = useRef(false);
|
const cancelBlur = useRef(false);
|
||||||
const mode = data.labelMode || "fixed";
|
const mode = data.labelMode || "fixed";
|
||||||
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
|
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
|
||||||
|
// 这里只 trim/补默认名,maxLength 是输入控件限制;UTF-8 字节和数据合法性仍由命令/格式层负责。
|
||||||
function saveTitle() {
|
function saveTitle() {
|
||||||
const name = title.trim() || "未命名分区";
|
const name = title.trim() || "未命名分区";
|
||||||
if (name !== data.title) editing.frame(id, { title: name });
|
if (name !== data.title) editing.frame(id, { title: name });
|
||||||
setTitle(name);
|
setTitle(name);
|
||||||
}
|
}
|
||||||
|
// title effect 无论是否正编辑都跟随外部值,外部改名可能替换未提交草稿;局部保存后也不等待远端 ACK。
|
||||||
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
|
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
|
||||||
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
|
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
|
||||||
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
|
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
|
||||||
|
// 折叠按钮只提交 presentation 候选,不直接隐藏/删除后代;实际尺寸恢复和投影由动作实现处理。
|
||||||
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
|
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
|
||||||
|
// 所有端口都用 source 类型配合外层连接模式;方向差异由 Start/End 能力开关表达。
|
||||||
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
|
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
|
||||||
return <>
|
return <>
|
||||||
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
|
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
|
||||||
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
||||||
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
|
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
|
||||||
|
// 这里优先按 local node.id 搜索,未同时限定 owner;旧协议跨 board 重名时取首个匹配,不能当作全局唯一身份。
|
||||||
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
||||||
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
|
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
|
||||||
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
||||||
@@ -190,6 +191,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
|
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
|
||||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||||
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
|
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
|
||||||
|
// checkpoint 只保存起点,没有对应 token 或原子结束动作;取消能力取决于旧节点组件的回退处理。
|
||||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||||
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
|
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
|
||||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||||
@@ -225,6 +227,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||||
tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId);
|
tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId);
|
||||||
const child = document.nodes.at(-1)!;
|
const child = document.nodes.at(-1)!;
|
||||||
|
// 使用 child 的 owner 局部坐标,未再投回根场景;Promise 的异步拒绝也不被外围同步 catch 捕获。
|
||||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||||
setBranchRequest(null);
|
setBranchRequest(null);
|
||||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||||
@@ -297,6 +300,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
|
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
|
||||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||||
if (dragSession.current !== "active") return;
|
if (dragSession.current !== "active") return;
|
||||||
|
// 先恢复 idle 才 await 后续成员移动;第二次拖动的防护依赖 tree 的保护节点/队列,而非这个状态持续锁定。
|
||||||
const origins = new Map(dragOrigins.current);
|
const origins = new Map(dragOrigins.current);
|
||||||
dragSession.current = "idle"; dragOrigins.current.clear(); setDraggedIds([]); tree.setDragging(false);
|
dragSession.current = "idle"; dragOrigins.current.clear(); setDraggedIds([]); tree.setDragging(false);
|
||||||
if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) {
|
if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) {
|
||||||
@@ -330,6 +334,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
|
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
|
||||||
|
// Promise.all 任一拒绝不取消其它组,也不回滚已完成的 hop;失败队首还可能保持未决 Promise 等待重试。
|
||||||
await Promise.all([...byOwner].map(async ([key, group]) => {
|
await Promise.all([...byOwner].map(async ([key, group]) => {
|
||||||
let source = key.split("/")[0];
|
let source = key.split("/")[0];
|
||||||
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
||||||
@@ -392,6 +397,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
|
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
|
||||||
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
|
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
|
||||||
}
|
}
|
||||||
|
// 文件选择完成后才消费保存的分支意图;不带源节点代次,实际创建前只按当前显示 ID 查 owner。
|
||||||
const request = pendingImage.current; pendingImage.current = null;
|
const request = pendingImage.current; pendingImage.current = null;
|
||||||
const kind = imageKind.current;
|
const kind = imageKind.current;
|
||||||
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
|
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
|
||||||
@@ -417,6 +423,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
|
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
|
||||||
setImageUploading(true);
|
setImageUploading(true);
|
||||||
try {
|
try {
|
||||||
|
// 演示 FileReader/Image 没有卸载取消信号或 await 后身份检查;回调仍使用本次调用捕获的创建函数。
|
||||||
const src = await new Promise<string>((resolve, reject) => {
|
const src = await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => resolve(String(reader.result));
|
reader.onload = () => resolve(String(reader.result));
|
||||||
@@ -447,6 +454,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
try {
|
try {
|
||||||
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
||||||
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
||||||
|
// await 前检查的是本次 render 的根;读完不重新核对子图关系/画布身份,tree.dispatch 准入只能覆盖其自身护栏。
|
||||||
const document = decodeCanvas(await file.text());
|
const document = decodeCanvas(await file.text());
|
||||||
if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。");
|
if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。");
|
||||||
dispatch({ type: "replace", document });
|
dispatch({ type: "replace", document });
|
||||||
@@ -460,6 +468,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
async function exportProject() {
|
async function exportProject() {
|
||||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||||
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
||||||
|
// 多 board bundle 仅导出文档 JSON;importProject 仍只接收普通单文档,并不是这个 bundle 的对称恢复器。
|
||||||
const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2);
|
const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2);
|
||||||
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
|
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -489,10 +498,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
return () => window.removeEventListener("keydown", keydown);
|
return () => window.removeEventListener("keydown", keydown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 上传失败行的“取消”只尝试移除占位节点,未直接 uploads.remove;任务/文件/预览寿命仍由上传 Hook 和卸载处理。
|
||||||
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
|
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
|
||||||
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
|
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
|
||||||
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
|
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
|
||||||
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
|
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
|
||||||
|
// 迁移准入检查 UI 文件读取与上传;真正排空 writer/本地恢复由 prepareMigration 再检查。
|
||||||
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
|
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||||
@@ -505,6 +516,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
|||||||
onBeforeDelete={async ({ nodes: removing }) => {
|
onBeforeDelete={async ({ nodes: removing }) => {
|
||||||
if (tree.busy || tree.transactionBusy) return false;
|
if (tree.busy || tree.transactionBusy) return false;
|
||||||
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
|
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
|
||||||
|
// 删除流程使用本次回调捕获的 tree 投影;await 后通过内存表补查,但没有全程冻结选择和历史的统一事务。
|
||||||
const linked = removing.filter((node) => node.data.subgraph);
|
const linked = removing.filter((node) => node.data.subgraph);
|
||||||
const parents = subgraphParents(tree.current.current);
|
const parents = subgraphParents(tree.current.current);
|
||||||
const referenced = removing.filter((node) => {
|
const referenced = removing.filter((node) => {
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
|
|||||||
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
|
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
|
||||||
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
|
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
|
||||||
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
|
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
|
||||||
|
// getSnapshot 是本次 render 的读取,不建立订阅;任务状态更新是否触发重渲染由父层负责。
|
||||||
const saved = object(element.data.ai), root = engine.getSnapshot();
|
const saved = object(element.data.ai), root = engine.getSnapshot();
|
||||||
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
||||||
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
|
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
|
||||||
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
|
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
|
||||||
const [propagate, setPropagate] = useState(saved.propagate === true);
|
const [propagate, setPropagate] = useState(saved.propagate === true);
|
||||||
|
// 输入 state 仅从初次 saved 初始化;同一挂载内外部 ai 配置变化不会自动覆盖本地草稿。
|
||||||
const image = ['image','plain-image'].includes(element.componentId);
|
const image = ['image','plain-image'].includes(element.componentId);
|
||||||
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
|
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
|
||||||
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
||||||
@@ -60,6 +62,8 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr
|
|||||||
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
||||||
// 状态文案先展示未解决取消/采用,再展示拒绝、已采用、等待受理与任务进度,防止把请求排队说成已执行。
|
// 状态文案先展示未解决取消/采用,再展示拒绝、已采用、等待受理与任务进度,防止把请求排队说成已执行。
|
||||||
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
||||||
|
// 保存配置与 startRun 是两个同步命令,没有合成一笔事务;配置成功而启动拒绝时,配置仍已修改。
|
||||||
|
// 命令接受也不等于服务端已受理或已持久确认,后续状态以 ledger/run 为准。
|
||||||
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
|
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
|
||||||
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
|
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
|
||||||
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
|
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
|
||||||
|
|||||||
@@ -149,8 +149,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
||||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||||
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
||||||
|
// current 是最近一次 React render 的快照,frame 合并期间可落后于 engine;需要强身份保证的入口仍须实时查询。
|
||||||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||||||
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
||||||
|
// 这里只呈现 rejected/disabled,不捕获同步异常,也不在成功时自动清除旧错误文案。
|
||||||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||||||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -204,6 +206,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
|
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
|
||||||
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
|
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
|
||||||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||||||
|
// 入参 fontSize 未进入这个 Geometry patch;字体大小修改应走单独 data/style 命令,而不是几何租约。
|
||||||
report(engine.sessions.update(viewId, target(id), patch));
|
report(engine.sessions.update(viewId, target(id), patch));
|
||||||
}
|
}
|
||||||
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
||||||
@@ -217,6 +220,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||||||
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
|
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
|
||||||
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
||||||
|
// 返回分配的 ID 不代表创建成功:无 tx 时这里只 report 拒绝,调用方若要依赖结果须检查事务状态。
|
||||||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -229,6 +233,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||||
}
|
}
|
||||||
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
||||||
|
// 资源键只反映当前投影的 nodeId/assetId,身份和 URL 可用性由 resourceView/engine 重新核验。
|
||||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||||
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -245,6 +250,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||||
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
||||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||||
|
// “已同步”文案依据这些 ledger/冲突计数,不是所有资源解码、所有后台任务或远端生成全部完成的证明。
|
||||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||||
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -286,6 +292,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
|
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
|
||||||
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
||||||
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
||||||
|
// canFitImage 在显示上因任意视图的 gesture 都禁用;真正命令能否执行仍由 engine.fitImage 再判断。
|
||||||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||||||
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
|
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
|
||||||
// Runtime-only publications must not invalidate every card's editing context.
|
// Runtime-only publications must not invalidate every card's editing context.
|
||||||
@@ -301,6 +308,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||||
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
||||||
|
// data 命令收到的是这次传入的 textStyle 对象;本层不读取并深合并旧样式,合并语义由 editor 定义。
|
||||||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||||||
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||||||
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
||||||
@@ -318,6 +326,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||||||
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||||
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
||||||
|
// owner 的坐标 transform 在 begin 时捕获;token 护栏只核手势身份,归属变化是否合法由 sessions 校验。
|
||||||
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
||||||
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||||
},
|
},
|
||||||
@@ -338,12 +347,14 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
}),[engine,viewId,report]);
|
}),[engine,viewId,report]);
|
||||||
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
|
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
|
||||||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||||||
|
// rendererCommands 直接委托 editor;它不是额外按 viewId/渲染目标绑定的权限层,扩展 renderer 必须遵守调用契约。
|
||||||
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
|
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
|
||||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||||
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
|
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
|
||||||
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
|
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
|
||||||
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
|
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
|
||||||
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
|
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
|
||||||
|
// 复制完成回调只比较 engine/viewId,未另设卸载信号;文件暂存、复制和导出均不因关闭面板而自动回滚。
|
||||||
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
|
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
|
||||||
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
||||||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
||||||
@@ -369,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
|
|
||||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||||
onNodeDragStart={(event, node, nodes) => {
|
onNodeDragStart={(event, node, nodes) => {
|
||||||
|
// 起始位置表借用投影 position 引用;后续 preview 必须保持不可变更新,不能原地篡改这些起点。
|
||||||
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
||||||
// 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。
|
// 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。
|
||||||
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
||||||
@@ -419,9 +431,11 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
|||||||
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
||||||
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
||||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
||||||
|
// 初始视口只捕获一次;同一挂载内 props.initialViewport 变化不会重新定位,切项目应由宿主正确重建/切会话。
|
||||||
const [handoffViewport]=useState(initialViewport);
|
const [handoffViewport]=useState(initialViewport);
|
||||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||||
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
||||||
|
// attach/viewport 在 effect 中同步执行,未捕获异常;attached 是显示门闩,不负责服务端加载或持久化就绪。
|
||||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,17 @@ export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectE
|
|||||||
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
|
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
|
||||||
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
|
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
|
||||||
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
||||||
|
// scope 只比较 engine/canvas,不带挂载代次;同一目标卸载后的已兑现回调并非由此 ref 自动取消。
|
||||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||||
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
|
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
|
||||||
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
|
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
|
||||||
|
// control 接收已经开始的 Promise:同步调用抛错不在它的 catch 内;这里也不阻止多次点任务按钮。
|
||||||
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
|
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
|
||||||
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
|
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
|
||||||
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
|
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
|
||||||
|
// 64KiB 只是后缀为 .json 时的格式探测;真正解析、校验、文件持久保存和逐步加入由 runtime 负责。
|
||||||
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
|
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
|
||||||
|
// “重试源文件校验/保存”调用 engine.retry,作用于项目待重试工作,不是只重试当前展示的 job。
|
||||||
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
|
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
|
||||||
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
|
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
|
||||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
|
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function PlanStructureReview({plan}:{plan:ConflictPlan}){
|
|||||||
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
|
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
|
||||||
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
|
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
|
||||||
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
|
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
|
||||||
|
// busy 是 React 状态护栏,不是同步锁;真正的会话互斥与持久屏障仍由 engine 校验。
|
||||||
// busy防重复动作;frozen在开始应用后锁住选择,保存异常时允许原方案重试,明确拒绝才解除冻结。
|
// busy防重复动作;frozen在开始应用后锁住选择,保存异常时允许原方案重试,明确拒绝才解除冻结。
|
||||||
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
||||||
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
|
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
|
||||||
@@ -75,13 +76,16 @@ export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngi
|
|||||||
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
|
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
|
||||||
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
|
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
|
||||||
},[engine]);
|
},[engine]);
|
||||||
|
// cancel/apply 的 await 之后不另核对 live 或 engine 代次;宿主应按 engine 生命周期挂载此面板。
|
||||||
/** 已有session且正在操作时不允许取消;否则等待engine解除恢复屏障,成功才关闭,失败留窗提示。 */
|
/** 已有session且正在操作时不允许取消;否则等待engine解除恢复屏障,成功才关闭,失败留窗提示。 */
|
||||||
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||||
/** 有plan则resolve逐项方案,无待处理操作则apply采用最新根;拒绝结果展示原因,异常保留冻结方案供重试。 */
|
/** 有plan则resolve逐项方案,无待处理操作则apply采用最新根;拒绝结果展示原因,异常保留冻结方案供重试。 */
|
||||||
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||||
|
// plan 是会话生成的本地预览,不等于已发布/已持久化;只有 resolve/apply 的结果决定是否成功关闭。
|
||||||
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
|
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
|
||||||
const canonical=session?.canonical.document;
|
const canonical=session?.canonical.document;
|
||||||
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
|
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
|
||||||
|
// records 是准备完成时的一次操作清单;面板不逐帧订阅 ledger,恢复屏障负责保持会话前提。
|
||||||
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
|
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
|
||||||
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
|
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
|
||||||
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
|
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { fault } from "@/lib/canvas-core/values";
|
|||||||
import { errorText } from "@/lib/api";
|
import { errorText } from "@/lib/api";
|
||||||
|
|
||||||
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
|
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
|
||||||
|
// failed 不随 src 变化自动重置;宿主以资源 ID 作 key 隔离不同候选的预览实例。
|
||||||
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
|
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
|
||||||
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
|
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
|
||||||
type Candidate = { id: Id; title: string };
|
type Candidate = { id: Id; title: string };
|
||||||
@@ -25,6 +26,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
|||||||
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
|
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
|
||||||
const dismiss=()=>{lifecycle.current?.abort();close();};
|
const dismiss=()=>{lifecycle.current?.abort();close();};
|
||||||
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
|
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
|
||||||
|
// target 只在首次挂载捕获;canvasId/engine 更换时不能指望它自动变成新目标,宿主应重建面板。
|
||||||
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
|
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
|
||||||
// 记录当前账户代次与工作区,收集已打开/正在打开的其它项目;不扫描服务端全部画布。
|
// 记录当前账户代次与工作区,收集已打开/正在打开的其它项目;不扫描服务端全部画布。
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
@@ -48,6 +50,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
|||||||
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
|
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 候选只在此 effect 扫描一次,不订阅其它项目后续新增/移除的资源;真正插入仍需重新授权校验。
|
||||||
setItems([...candidates.values()]);setLoading(false);
|
setItems([...candidates.values()]);setLoading(false);
|
||||||
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
|
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
|
||||||
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
|
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
|
||||||
@@ -69,6 +72,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
|||||||
},{signal});
|
},{signal});
|
||||||
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
|
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
|
||||||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||||
|
// 这两个宿主回调仍在 try 内;它们抛错会显示失败,但已经提交的创建事务不会回滚。
|
||||||
if(!signal.aborted){onAdded(id);close();}
|
if(!signal.aborted){onAdded(id);close();}
|
||||||
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
|
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
|
||||||
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
|
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type IconSource = "all" | "lucide" | "tabler";
|
|||||||
* 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。
|
* 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。
|
||||||
* 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */
|
* 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */
|
||||||
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
|
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
|
||||||
|
// 每次扫描全部目录并归一化检索串,无预建倒排索引;返回新数组但条目仍共享静态记录引用。
|
||||||
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
|
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
|
||||||
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
|
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
|
||||||
&& (category === "all" || (category === "common" ? icon.common : category === "other" ? !icon.categories.length : icon.categories.includes(category)))
|
&& (category === "all" || (category === "common" ? icon.common : category === "other" ? !icon.categories.length : icon.categories.includes(category)))
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
|||||||
const rows = Math.ceil(matches.length / columns);
|
const rows = Math.ceil(matches.length / columns);
|
||||||
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
|
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
|
||||||
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
|
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
|
||||||
|
// 虚拟化只限制挂载按钮,matches 仍保留完整同步筛选结果;不是分批解析/下载目录。
|
||||||
const visible = matches.slice(firstRow * columns, lastRow * columns);
|
const visible = matches.slice(firstRow * columns, lastRow * columns);
|
||||||
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
|
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
|
||||||
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
|
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
|
||||||
@@ -52,8 +53,10 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
|||||||
else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height;
|
else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height;
|
||||||
setScrollTop(grid.current.scrollTop);
|
setScrollTop(grid.current.scrollTop);
|
||||||
}
|
}
|
||||||
|
// 帧未单独取消且不带筛选代次;执行时读最新 grid,筛选变化可能让同一 index 指向另一条结果。
|
||||||
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
||||||
}
|
}
|
||||||
|
// Portal 直接使用 document.body,仅供客户端挂载;dialog 角色不自动提供焦点陷阱或背景 inert。
|
||||||
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
|
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
|
||||||
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
|
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
|
||||||
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
|
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function LegacyCanvasMigration(props:Props){
|
|||||||
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
|
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
|
||||||
const read=()=>readCanvasMigration(localStorage,scope);
|
const read=()=>readCanvasMigration(localStorage,scope);
|
||||||
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
|
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
|
||||||
|
// inspect 没有独立的在途 Promise 合并;storage/刷新可重复调用,跨页互斥由 Web Locks 与持久记录保证。
|
||||||
async function inspect(){
|
async function inspect(){
|
||||||
if(attempt.current||!current())return;
|
if(attempt.current||!current())return;
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
@@ -67,6 +68,7 @@ export function LegacyCanvasMigration(props:Props){
|
|||||||
refresh.current=inspect;void inspect();
|
refresh.current=inspect;void inspect();
|
||||||
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
|
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
|
||||||
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
|
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
|
||||||
|
// storage 事件来自其它文档,本页 setItem 不靠此事件更新 UI;也未按 storageArea 区分其它 Storage 来源。
|
||||||
const storage=(event:StorageEvent)=>{
|
const storage=(event:StorageEvent)=>{
|
||||||
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
|
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
|
||||||
// Never discard another view's in-memory content because a record vanished.
|
// Never discard another view's in-memory content because a record vanished.
|
||||||
@@ -78,6 +80,7 @@ export function LegacyCanvasMigration(props:Props){
|
|||||||
};
|
};
|
||||||
window.addEventListener('storage',storage);
|
window.addEventListener('storage',storage);
|
||||||
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
|
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
|
||||||
|
// release 在此是尽力异步清理,不等待也不安装 catch;它的拒绝不进入面板 error 状态。
|
||||||
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
|
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
|
||||||
// The port ref follows renders without re-acquiring a shared editing lease.
|
// The port ref follows renders without re-acquiring a shared editing lease.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -85,6 +88,7 @@ export function LegacyCanvasMigration(props:Props){
|
|||||||
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
|
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
|
||||||
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
|
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
|
||||||
async function upgrade(){
|
async function upgrade(){
|
||||||
|
// attempt 是同步 ref,先于首次 await 设置,区别于仅用于渲染的 running 状态。
|
||||||
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
|
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
|
||||||
attempt.current=true;setRunning(true);setError('');
|
attempt.current=true;setRunning(true);setError('');
|
||||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height
|
|||||||
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
|
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
|
||||||
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
|
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
|
||||||
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
|
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
|
||||||
|
// transaction 也直接返回拒绝,不执行传入事务函数;这不限制自定义 renderer 自行发起的外部副作用。
|
||||||
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
|
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
|
||||||
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
|
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
|
||||||
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
|
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
|
||||||
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
|
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
|
||||||
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
|
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
|
||||||
|
// fitted 在调用 fitView 前置 true;期间依赖变化会取消迟到通知,但不会重新 fit。拒绝也没有本地 catch/重试。
|
||||||
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
|
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -38,12 +40,14 @@ export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,
|
|||||||
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
|
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
|
||||||
const projection=useMemo(()=>{
|
const projection=useMemo(()=>{
|
||||||
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
|
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
|
||||||
|
// 每条边端点都追加描述,不按 handleId 去重;PreparingNode 使用临时索引 key,仅供预览定位。
|
||||||
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
|
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
|
||||||
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
|
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
|
||||||
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
||||||
},[preview.document,canvasId,workspaceId,registry,catalog]);
|
},[preview.document,canvasId,workspaceId,registry,catalog]);
|
||||||
// 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。
|
// 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。
|
||||||
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
|
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
|
||||||
|
// onMoveEnd 同时记录用户移动;首次 fit 完成通知与用户移动并无额外竞争仲裁,由外层决定交接采用。
|
||||||
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
|
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
|
||||||
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
|
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
|
||||||
<CanvasRendererCommandsContext.Provider value={commands}>
|
<CanvasRendererCommandsContext.Provider value={commands}>
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
|
|||||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||||
const { zoom } = useViewport();
|
const { zoom } = useViewport();
|
||||||
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
|
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
|
||||||
|
// 像素比在 render 时读取,不单独监听其变化;zoom 订阅或其它重渲染才重新计算此启发式。
|
||||||
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||||
// 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。
|
// 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。
|
||||||
const pointerStart = useRef({ x: 0, y: 0 });
|
const pointerStart = useRef({ x: 0, y: 0 });
|
||||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||||
// 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。
|
// 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。
|
||||||
// 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。
|
// 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。
|
||||||
|
// pointerStart 不按 pointerId 分组,属于点击位移辅助;键盘入口也不在这里检查 branchable,由动作实现兜底。
|
||||||
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
|
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
|
||||||
return (
|
return (
|
||||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHook
|
|||||||
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
||||||
// 只有rendered.id匹配当前节点才采用incarnation;有新版scope却缺少合法rendered身份时拒绝编辑。
|
// 只有rendered.id匹配当前节点才采用incarnation;有新版scope却缺少合法rendered身份时拒绝编辑。
|
||||||
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
|
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
|
||||||
|
// current 在动作触发时读取快照,不建立订阅;后续状态变化不会仅因这个检查而触发组件重渲染。
|
||||||
// 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。
|
// 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。
|
||||||
return useMemo(()=>{if(!scope)return editing;
|
return useMemo(()=>{if(!scope)return editing;
|
||||||
// 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。
|
// 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。
|
||||||
// candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。
|
// candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。
|
||||||
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
|
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
|
||||||
// finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。
|
// finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。
|
||||||
|
// 这里不捕获 gesture token;同视图同目标的新手势也能通过,精确手势身份须由创建者的租约保护。
|
||||||
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
|
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
|
||||||
// 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。
|
// 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。
|
||||||
const scoped:typeof editing={...editing,
|
const scoped:typeof editing={...editing,
|
||||||
@@ -61,7 +63,7 @@ export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
|||||||
// begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。
|
// begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。
|
||||||
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
|
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
|
||||||
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
|
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
|
||||||
// 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。
|
// 结束手势要同时通过对象有效性与归属检查;这不是对任意迟到事件的完整 token 隔离。
|
||||||
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
|
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
|
||||||
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
|
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
|
||||||
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
|
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ export function useNodeInternalsUpdate(id:string){
|
|||||||
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
|
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
|
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
|
||||||
|
// batch 首次创建时捕获 update;同 store 后续使用者沿用该更新器,不在每次 effect 中替换。
|
||||||
const owner=batch.acquire(id);lease.current=owner;
|
const owner=batch.acquire(id);lease.current=owner;
|
||||||
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
|
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
|
||||||
},[scope,update,id]);
|
},[scope,update,id]);
|
||||||
// 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。
|
// 回调通过ref读取当前租约;旧调用者保存的同一回调,在本组件 id 改变后会转向新 id 的租约。
|
||||||
|
// effect 尚未安装/已清理则无动作;它不把回调永久绑定到最初 id。
|
||||||
return useCallback(()=>lease.current?.invalidate(),[]);
|
return useCallback(()=>lease.current?.invalidate(),[]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
|||||||
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
|
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
|
||||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||||
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
|
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
|
||||||
|
// 每次控件变化立即派发;没有在本组件合并拖动取色等多次输入为一个 undo 项,也没有等待服务端确认。
|
||||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||||
|
// 显示默认值不写回 data;命令校验仍由 editing 实现,select 的类型断言不是运行时 schema 验证。
|
||||||
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
|
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
|
||||||
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
|
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
|
||||||
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
|
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export function NodeTitle({ id, title, selected,renderedTarget }: { id: string;
|
|||||||
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
|
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
|
||||||
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
|
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
|
||||||
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
|
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
|
||||||
|
// rename 是同步动作入口,不等待远端 ACK;若实现抛错,下方标记复位与退出也不会自动执行。
|
||||||
function save() {
|
function save() {
|
||||||
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
|
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
|
||||||
cancelled.current = false; setEditing(false);
|
cancelled.current = false; setEditing(false);
|
||||||
}
|
}
|
||||||
// Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。
|
// Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。
|
||||||
|
// maxLength 是浏览器 UTF-16 长度限制;此 save 只 trim/去空/比旧值,不另做 UTF-8 字节校验。
|
||||||
// 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。
|
// 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。
|
||||||
if (editing) return <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => setValue(event.target.value)} onBlur={save}
|
if (editing) return <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => setValue(event.target.value)} onBlur={save}
|
||||||
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
|
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function PlainImageNode(props: NodeProps<CanvasNode>) {
|
|||||||
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
|
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
|
||||||
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
|
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
|
||||||
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||||
|
// 该估算只影响托管 URL 的 variant;不是独立下载任务,不会为普通外部图片生成预览资源。
|
||||||
// managedImageSource按资源格式选择可用地址,移动/缩放等编辑行为仍由TransformableNode处理。
|
// managedImageSource按资源格式选择可用地址,移动/缩放等编辑行为仍由TransformableNode处理。
|
||||||
return <TransformableNode {...props} className={styles.plainImageFrame}><CanvasImage id={props.id} className={styles.plainImage} src={managedImageSource(props.data.src, preview)} title={props.data.title} /></TransformableNode>;
|
return <TransformableNode {...props} className={styles.plainImageFrame}><CanvasImage id={props.id} className={styles.plainImage} src={managedImageSource(props.data.src, preview)} title={props.data.title} /></TransformableNode>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
export function createRetainedProjection(){
|
export function createRetainedProjection(){
|
||||||
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
|
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
|
||||||
const tagged=new WeakMap<object,object>();
|
const tagged=new WeakMap<object,object>();
|
||||||
|
// previous 强引用最近一批输入/输出;WeakMap 不等于整个实例完全不持有投影,空 ids 才清这份整批引用。
|
||||||
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
|
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
|
||||||
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
|
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
|
||||||
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
|
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
|
||||||
|
// 清 previous 不清 tagged;同一对象日后再次被保留,仍可能复用已有带标记副本。
|
||||||
if(!ids.size){previous=undefined;return{nodes,edges};}
|
if(!ids.size){previous=undefined;return{nodes,edges};}
|
||||||
// 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。
|
// 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。
|
||||||
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
|
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { CanvasNodeData } from "@/lib/canvas-elements";
|
|||||||
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
|
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
|
||||||
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
|
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
|
||||||
export function textAppearance(data: CanvasNodeData): CSSProperties {
|
export function textAppearance(data: CanvasNodeData): CSSProperties {
|
||||||
|
// 每次生成新的扁平 CSS 对象;不缓存节点引用,也不会写回默认值到 data。
|
||||||
const style = data.textStyle || {};
|
const style = data.textStyle || {};
|
||||||
return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5,
|
return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5,
|
||||||
fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };
|
fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
|||||||
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
||||||
if (!editing.beginTransform) editing.begin(id);
|
if (!editing.beginTransform) editing.begin(id);
|
||||||
if (!node.selected) editing.select(id);
|
if (!node.selected) editing.select(id);
|
||||||
|
// node 是 flow.getNode 返回的引用,不深克隆;“起点快照”依赖外层不可变更新约定,不能原地修改该节点。
|
||||||
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||||
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
|
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
|
||||||
event.preventDefault();event.stopPropagation();
|
event.preventDefault();event.stopPropagation();
|
||||||
@@ -87,6 +88,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
|||||||
if (!ownsPointerCapture(gesture.current,event)) return;
|
if (!ownsPointerCapture(gesture.current,event)) return;
|
||||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||||
const { node, start, direction } = gesture.current!;
|
const { node, start, direction } = gesture.current!;
|
||||||
|
// resizing 模式显式传 Shift 布尔值,未按 Shift 即解锁比例;普通角落传 undefined 沿用图片/图标默认比例规则。
|
||||||
const change = direction === "rotate"
|
const change = direction === "rotate"
|
||||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
|
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
|
||||||
@@ -100,6 +102,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
|||||||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||||||
const current = gesture.current; if (!current) return;
|
const current = gesture.current; if (!current) return;
|
||||||
gesture.current = null;
|
gesture.current = null;
|
||||||
|
// cancel/commit 调用与释放间没有 finally;Provider/lease 抛错时可能跳过 release,需要遵守动作异常契约。
|
||||||
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||||
releasePointerCapture(current.capture,current.pointerId);
|
releasePointerCapture(current.capture,current.pointerId);
|
||||||
}
|
}
|
||||||
@@ -107,9 +110,11 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
|||||||
function finish(event: PointerEvent<HTMLElement>) {
|
function finish(event: PointerEvent<HTMLElement>) {
|
||||||
if(!gesture.current)return;event.stopPropagation();
|
if(!gesture.current)return;event.stopPropagation();
|
||||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
|
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
|
||||||
|
// 当前手势引用已清空;若 commit 抛错,不会自动恢复引用或执行后面的 capture 释放。
|
||||||
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
||||||
releasePointerCapture(current.capture,current.pointerId);
|
releasePointerCapture(current.capture,current.pointerId);
|
||||||
}
|
}
|
||||||
|
// 模式关闭/取消选中本身不调用 cancelGesture;实际手势结束还依赖 pointerup/cancel、失焦或卸载。
|
||||||
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
|
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
|
||||||
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
|
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
|
||||||
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
|
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
|
||||||
@@ -139,6 +144,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
|||||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
||||||
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
|
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
|
||||||
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||||
|
// 键盘按键没有 pointer capture,也不经过 move;preview/commit 同步连用,失败清理由租约实现承担。
|
||||||
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
|
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
|
||||||
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
|
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
|
||||||
}} />;
|
}} />;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,le
|
|||||||
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
|
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
|
||||||
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
||||||
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
|
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
|
||||||
|
// 只读取公共投影信封;此组件本身没有 TransformableNode,也不安装任何编辑动作处理器。
|
||||||
const fallback=data.__headlessFallback;
|
const fallback=data.__headlessFallback;
|
||||||
return <div className={`${styles.node} ${selected?styles.selectedNode:""}`} style={{width:"100%",height:"100%",padding:14,transform:`rotate(${data.rotation||0}deg)`}} role="note" aria-label="暂不支持编辑的元素">
|
return <div className={`${styles.node} ${selected?styles.selectedNode:""}`} style={{width:"100%",height:"100%",padding:14,transform:`rotate(${data.rotation||0}deg)`}} role="note" aria-label="暂不支持编辑的元素">
|
||||||
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
|
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
|
||||||
@@ -27,6 +28,7 @@ export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
|||||||
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
|
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
|
||||||
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
|
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
|
||||||
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
|
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
|
||||||
|
// 采用端点派生的通用曲线,不重放未知模型的自定义 path/marker;保留数据与视觉完全还原是两件事。
|
||||||
const [path]=getBezierPath(props);
|
const [path]=getBezierPath(props);
|
||||||
return <g role="img" aria-label="暂不支持编辑的连线"><title>原始连线内容已保留</title><BaseEdge id={props.id} path={path} style={{stroke:'#aa968b',strokeWidth:1.5}} /></g>;
|
return <g role="img" aria-label="暂不支持编辑的连线"><title>原始连线内容已保留</title><BaseEdge id={props.id} path={path} style={{stroke:'#aa968b',strokeWidth:1.5}} /></g>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
|||||||
const reference = useRef(tree); reference.current = tree;
|
const reference = useRef(tree); reference.current = tree;
|
||||||
const wake = useRef<() => void>(() => {});
|
const wake = useRef<() => void>(() => {});
|
||||||
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
|
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
|
||||||
|
// 这些 ref/state 不因 workspace/root/live 改变而清空;宿主应按作用域重建 Hook 所在视图,避免沿用旧跟踪表。
|
||||||
const cache = useRef(new Map<string, AIRun>());
|
const cache = useRef(new Map<string, AIRun>());
|
||||||
const edgeStates = useRef(new Map<string, Set<string>>());
|
const edgeStates = useRef(new Map<string, Set<string>>());
|
||||||
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
|
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
|
||||||
@@ -45,6 +46,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
|||||||
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
|
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
|
||||||
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
|
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
|
||||||
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
|
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
|
||||||
|
// alive 只控制继续分页/发布等交接点;await 返回后仍可能先写共享 ref,不是每次赋值都有代次护栏。
|
||||||
async function discover() {
|
async function discover() {
|
||||||
let cursor = ""; const seen = new Set<string>();
|
let cursor = ""; const seen = new Set<string>();
|
||||||
do {
|
do {
|
||||||
@@ -77,6 +79,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
|||||||
}
|
}
|
||||||
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
|
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
|
||||||
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
|
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
|
||||||
|
// dirty 只比较该 run 原游标和状态;同游标/同状态下其它响应字段变化不一定立即发布给 React。
|
||||||
if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true;
|
if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true;
|
||||||
cache.current.set(id, current);
|
cache.current.set(id, current);
|
||||||
} while (current.has_more && alive);
|
} while (current.has_more && alive);
|
||||||
@@ -115,11 +118,13 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
|||||||
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
|
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
|
||||||
}
|
}
|
||||||
/** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */
|
/** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */
|
||||||
|
// 此写请求没有轮询 effect 的 signal,也未检查 live;按键是否可用由调用方和服务端权限共同约束。
|
||||||
async function cancel(run: AIRun) {
|
async function cancel(run: AIRun) {
|
||||||
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
|
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
|
||||||
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
|
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
|
||||||
}
|
}
|
||||||
/** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
|
/** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
|
||||||
|
// run/apply 同样不由 effect 清理自动取消;卸载只停跟踪,不撤销已经发起的命令副作用。
|
||||||
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
|
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
|
||||||
await reference.current.prepareApply(targetBoard, node);
|
await reference.current.prepareApply(targetBoard, node);
|
||||||
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
|
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
|||||||
if (alive.current) { setSync(next); backup(); }
|
if (alive.current) { setSync(next); backup(); }
|
||||||
}) : null);
|
}) : null);
|
||||||
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
|
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
|
||||||
|
// writer 通知捕获创建时的 backup/key;latest 与 pausedRef 保持当前值,key/workspace/board 改变须重建 Hook 所在视图。
|
||||||
function backup() {
|
function backup() {
|
||||||
if (pausedRef.current) return;
|
if (pausedRef.current) return;
|
||||||
try {
|
try {
|
||||||
const current = latest.current;
|
const current = latest.current;
|
||||||
|
// dirty 使用包含 viewport 的指纹,而 writer.needsSave 忽略 viewport;两者用途不同,不可互换解释。
|
||||||
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
|
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
|
||||||
} catch { /* Storage failure is surfaced by the effect below. */ }
|
} catch { /* Storage failure is surfaced by the effect below. */ }
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
|||||||
try { localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(latest.current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(latest.current) !== writer.saved : false })); }
|
try { localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(latest.current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(latest.current) !== writer.saved : false })); }
|
||||||
catch { setSync({ status: "error", version: writer?.version || 1, error: "local_backup_failed" }); }
|
catch { setSync({ status: "error", version: writer?.version || 1, error: "local_backup_failed" }); }
|
||||||
}, 250);
|
}, 250);
|
||||||
|
// 此处只入队,不 await/捕获 running 的拒绝;CanvasSync 的通知与回调需遵守它自身的异常约定。
|
||||||
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
|
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
|
||||||
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
|
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
|
||||||
}, [document, writer, key, paused]);
|
}, [document, writer, key, paused]);
|
||||||
@@ -48,6 +51,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
|||||||
// Writer and key are stable for this board's mounted editor.
|
// Writer and key are stable for this board's mounted editor.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [writer, key]);
|
}, [writer, key]);
|
||||||
|
// 同步 localStorage 写入只保证调用返回时 API 未抛错;没有回读、版本日志或刷盘确认。
|
||||||
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
|
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
|
||||||
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
|
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
|
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
|
||||||
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
||||||
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
|
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
|
||||||
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
|
* X-Expected-Account经代理转发,由上游核对目标账号;本地前后检查不能证明已经发出的写入未生效。 */
|
||||||
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
|
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
|
||||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||||
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
|
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
|
||||||
@@ -88,6 +88,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
function resolveRecovery(boardId: string, restore: boolean) {
|
function resolveRecovery(boardId: string, restore: boolean) {
|
||||||
if(migrationBlocked())return;
|
if(migrationBlocked())return;
|
||||||
const draft = pendingRecovery.current.get(boardId);
|
const draft = pendingRecovery.current.get(boardId);
|
||||||
|
// update 没有成功返回值;若其它准入条件拒绝恢复,下面仍会移除候选/记录,宿主须先保证可恢复。
|
||||||
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
||||||
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
||||||
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
||||||
@@ -107,6 +108,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
if (writer) writer.adopt(value);
|
if (writer) writer.adopt(value);
|
||||||
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
|
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
|
||||||
if (!mounted.current) return;
|
if (!mounted.current) return;
|
||||||
|
// 显示的是最近通知的 writer 状态,不是遍历全部 writer 后算出的项目汇总;错误只在显式路径清除。
|
||||||
setStatus(state.status); if (state.error) setError(state.error);
|
setStatus(state.status); if (state.error) setError(state.error);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -195,6 +197,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
|
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
|
||||||
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
||||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||||
|
// ensure 自身不复用 loading/在途 Promise;并行调用同一缺失 board 可能重复读取。loading 只服务下方可见子图 effect。
|
||||||
let document = current.current.get(boardId);
|
let document = current.current.get(boardId);
|
||||||
if (!document) {
|
if (!document) {
|
||||||
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
|
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
|
||||||
@@ -253,6 +256,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
if(migrationBlocked())return;
|
if(migrationBlocked())return;
|
||||||
for (const [boardId, document] of current.current) try {
|
for (const [boardId, document] of current.current) try {
|
||||||
if (pendingRecovery.current.has(boardId)) continue;
|
if (pendingRecovery.current.has(boardId)) continue;
|
||||||
|
// canvasPayload 会过滤未上传占位及其边;此草稿记录不能保存原文件或保证刷新后恢复上传。
|
||||||
const writer = writers.current.get(boardId);
|
const writer = writers.current.get(boardId);
|
||||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
|
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
|
||||||
} catch { setError("local_backup_failed"); }
|
} catch { setError("local_backup_failed"); }
|
||||||
@@ -334,6 +338,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
function accept(result: CanvasCommandResult) {
|
function accept(result: CanvasCommandResult) {
|
||||||
const next = new Map(current.current);
|
const next = new Map(current.current);
|
||||||
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
|
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
|
||||||
|
// 每项 install 都立即改变 writer 基线;后项校验/合并抛错时,先前 writer 不自动回滚,Map 可能还没 publish。
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document;
|
const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document;
|
||||||
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
||||||
@@ -356,7 +361,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
return [...new Set(values.map((value) => value.board_id))];
|
return [...new Set(values.map((value) => value.board_id))];
|
||||||
}
|
}
|
||||||
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
|
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
|
||||||
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
|
* execute闭包复用同一body对象/key/version,但每次发送重新JSON编码;这些恢复信息只在内存,不保证刷新后继续。 */
|
||||||
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
||||||
if (retryOperation.current) throw new Error("canvas_busy");
|
if (retryOperation.current) throw new Error("canvas_busy");
|
||||||
return exclusive(async () => {
|
return exclusive(async () => {
|
||||||
@@ -365,11 +370,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
}
|
}
|
||||||
await flush();
|
await flush();
|
||||||
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
||||||
|
// body 只浅展开 payload,嵌套值仍共享引用;外部原地修改可能改变重试字节,不同于新版 FrozenRequest.body。
|
||||||
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
||||||
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
|
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
|
||||||
const execute = async (): Promise<CanvasCommandResult> => {
|
const execute = async (): Promise<CanvasCommandResult> => {
|
||||||
try {
|
try {
|
||||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
||||||
|
// accept 和历史/补读也在这个 try 内;采用中途失败不等于服务端命令未执行,只有下方分类决定是否保留重试。
|
||||||
const changed = accept(result);
|
const changed = accept(result);
|
||||||
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
|
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
|
||||||
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
||||||
@@ -464,6 +471,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
// A definite rejection performed zero writes. A retry may prepare fresh
|
// A definite rejection performed zero writes. A retry may prepare fresh
|
||||||
// versions; ambiguous transport failures must retain the exact body/key.
|
// versions; ambiguous transport failures must retain the exact body/key.
|
||||||
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
|
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
|
||||||
|
// 此处把所有 ApiError<500 当确定拒绝,没有核对 terminal 回执身份;它是旧协议假设,不是新版证据判定。
|
||||||
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
||||||
showMembers();
|
showMembers();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -474,6 +482,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
|
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
|
||||||
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
||||||
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
||||||
|
// Promise 只有成功出队才 resolve,没有 reject/卸载取消;失败留队或页面离开后不能把它当已完成屏障。
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
||||||
});
|
});
|
||||||
@@ -498,6 +507,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
|
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
|
||||||
function movePendingImage(source: string, target: string, id: string) {
|
function movePendingImage(source: string, target: string, id: string) {
|
||||||
if(migrationBlocked())throw new Error("canvas_busy");
|
if(migrationBlocked())throw new Error("canvas_busy");
|
||||||
|
// 这里仅检查迁移门闩,未复用 exclusive 的 paused/locked/待重试准入;调用者必须先满足更完整的交互条件。
|
||||||
const from = current.current.get(source)!, to = current.current.get(target)!;
|
const from = current.current.get(source)!, to = current.current.get(target)!;
|
||||||
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
|
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
|
||||||
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
|
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
|
||||||
@@ -553,6 +563,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
|||||||
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 分支中的多次 await 不持有跨全流程历史锁,也不重新比对 stack 顶项;宿主应串行发起历史动作。
|
||||||
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
|
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
|
||||||
if ("id" in entry) {
|
if ("id" in entry) {
|
||||||
if (isRedo) {
|
if (isRedo) {
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
|||||||
if (job.status === "uploading") return;
|
if (job.status === "uploading") return;
|
||||||
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
|
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
|
||||||
try {
|
try {
|
||||||
|
// Promise.all 不会因 ready 拒绝自动中止上传;失败 UI 可先出现,而底层请求仍可能完成。
|
||||||
const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]);
|
const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]);
|
||||||
// 结果返回后核对挂载、Map内仍是同一个job且未取消,旧任务不能写到后来同ID的新任务。
|
// 结果返回后核对挂载、Map内仍是同一个job且未取消,旧任务不能写到后来同ID的新任务。
|
||||||
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
|
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
|
||||||
|
// commit 是同步调用,不等待网络 ACK;抛错不会自动撤销它此前已发布的本地修改。
|
||||||
job.asset = asset; onCommit.current(job, asset); job.status = "uploaded"; changed();
|
job.asset = asset; onCommit.current(job, asset); job.status = "uploaded"; changed();
|
||||||
} catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } }
|
} catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } }
|
||||||
}
|
}
|
||||||
@@ -42,11 +44,13 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
|||||||
let value: ImageUploadDraft;
|
let value: ImageUploadDraft;
|
||||||
try { value = draft(localURL); } catch (error) { URL.revokeObjectURL(localURL); throw error; }
|
try { value = draft(localURL); } catch (error) { URL.revokeObjectURL(localURL); throw error; }
|
||||||
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
|
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
|
||||||
|
// 节点 ID 必须由草稿工厂保证唯一;同 ID set 会替换旧任务,但此处不会 abort/revoke 被替换的任务。
|
||||||
pending.current.set(job.node.id, job); changed();
|
pending.current.set(job.node.id, job); changed();
|
||||||
// Browser decoding runs locally, concurrently with the original-byte upload.
|
// Browser decoding runs locally, concurrently with the original-byte upload.
|
||||||
// 纯图片并行解码自然尺寸,限制初始长宽不超过480(不主动放大),再分别设置至少24px的显示尺寸。
|
// 纯图片并行解码自然尺寸,限制初始长宽不超过480(不主动放大),再分别设置至少24px的显示尺寸。
|
||||||
if (job.node.data.kind === "plain-image") {
|
if (job.node.data.kind === "plain-image") {
|
||||||
job.ready = new Promise<void>((resolve,reject) => {
|
job.ready = new Promise<void>((resolve,reject) => {
|
||||||
|
// 这个 Image 解码没有接入 controller 的取消,也没有显式移除 onload/onerror;回调以 Map 身份拒绝已移除任务。
|
||||||
const image = new window.Image(); image.onload = () => {
|
const image = new window.Image(); image.onload = () => {
|
||||||
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
|
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
|
||||||
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
|
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
|
||||||
@@ -60,6 +64,7 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
|||||||
void run(job);
|
void run(job);
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
// 每次重试仍调用上传接口,不直接采用 job.asset;幂等依赖相同 key,ready 失败也不会自动回滚服务端资源。
|
||||||
// hasPending包含失败但不包含uploaded,active只看uploading;loaded仅在正式图片加载后移除已上传预览。
|
// hasPending包含失败但不包含uploaded,active只看uploading;loaded仅在正式图片加载后移除已上传预览。
|
||||||
// retry复用job及幂等键;若ready曾拒绝,重试不会在这里重建尺寸解码Promise。
|
// retry复用job及幂等键;若ready曾拒绝,重试不会在这里重建尺寸解码Promise。
|
||||||
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
|
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
|
||||||
|
|||||||
@@ -1,95 +1,121 @@
|
|||||||
|
/* 当前 LandingPage 的局部配色、两栏首屏、可选预览卡片、功能区和响应式布局;预览交互由组件状态驱动,不使用真实画布模型。 */
|
||||||
|
/* 页面作用域内定义墨色/弱文字/边线变量,局部规则覆盖全局品牌类时使用 :global。 */
|
||||||
.page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
.page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||||
|
/* 移除触摸高亮并保留明确的键盘可见焦点描边。 */
|
||||||
.page a, .page button { -webkit-tap-highlight-color: transparent; }
|
.page a, .page button { -webkit-tap-highlight-color: transparent; }
|
||||||
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
|
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
|
||||||
|
/* 导航内容限制最大宽度,品牌、导航链接和右侧操作区横排。 */
|
||||||
.nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; }
|
.nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; }
|
||||||
.page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
|
.page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
|
||||||
.page :global(.brand img) { width: 30px; height: 30px; }
|
.page :global(.brand img) { width: 30px; height: 30px; }
|
||||||
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
||||||
.nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
|
.nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
|
||||||
.nav nav a:hover { color: var(--ink); }
|
.nav nav a:hover { color: var(--ink); }
|
||||||
|
/* margin-left:auto 将登录和小 CTA 推到最右侧,hover 只改变表面色。 */
|
||||||
.navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; }
|
.navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; }
|
||||||
.smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
|
.smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
|
||||||
.smallCta:hover { background: #f5f6f0; }
|
.smallCta:hover { background: #f5f6f0; }
|
||||||
|
/* 桌面首屏文案/预览用接近等宽的两列;clamp 让标题在边界内随视口缩放。 */
|
||||||
.hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; }
|
.hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; }
|
||||||
.eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; }
|
.eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; }
|
||||||
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||||
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
|
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
|
||||||
.hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; }
|
.hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; }
|
||||||
.hero h1 > span { position: relative; white-space: nowrap; }
|
.hero h1 > span { position: relative; white-space: nowrap; }
|
||||||
|
/* 标题装饰线通过伪元素绘制,不增加实际文本或影响行内宽度。 */
|
||||||
.hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; }
|
.hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; }
|
||||||
.heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; }
|
.heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; }
|
||||||
|
/* CTA 可换行,主按钮悬停上移 1px;旁边文字链接保持次级视觉权重。 */
|
||||||
.heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; }
|
.heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; }
|
||||||
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; }
|
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; }
|
||||||
.primary:hover { background: #41453b; translate: 0 -1px; }
|
.primary:hover { background: #41453b; translate: 0 -1px; }
|
||||||
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
|
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
|
||||||
.textLink:hover { color: #21221f; }
|
.textLink:hover { color: #21221f; }
|
||||||
|
/* 手写风附注和星形装饰仅参与视觉排版。 */
|
||||||
.heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
.heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||||
.scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; }
|
.scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; }
|
||||||
|
/* 预览外壳轻微旋转并设置阴影,min-width:0 防止网格项被内容撑宽。 */
|
||||||
.preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; }
|
.preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; }
|
||||||
.previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; }
|
.previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; }
|
||||||
.previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
|
.previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
|
||||||
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
|
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
|
||||||
.tinyLogo { font-size: 20px; color: #4c5841; }
|
.tinyLogo { font-size: 20px; color: #4c5841; }
|
||||||
|
/* 点阵预览固定宽高比,卡片和 SVG 连线在相同相对容器内绝对定位。 */
|
||||||
.scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; }
|
.scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; }
|
||||||
.wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
|
.wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
|
||||||
.wires circle { fill: #a3ad90; }
|
.wires circle { fill: #a3ad90; }
|
||||||
|
/* 预览卡片作为按钮可点击,hover 上移,activeCard 通过边框/阴影反馈当前选择。 */
|
||||||
.scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; }
|
.scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; }
|
||||||
.scene > button:hover { translate: 0 -3px; }
|
.scene > button:hover { translate: 0 -3px; }
|
||||||
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
|
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
|
||||||
.cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; }
|
.cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; }
|
||||||
|
/* 三类预览卡片各有位置、旋转和排版;纸角、艺术图与标签是局部装饰。 */
|
||||||
.ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; }
|
.ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; }
|
||||||
.ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; }
|
.ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; }
|
||||||
.cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; }
|
.cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; }
|
||||||
.paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; }
|
.paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; }
|
||||||
|
/* 艺术卡片给 SVG 可用高度,使用无填充线稿;便签另有底色与字号。 */
|
||||||
.scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; }
|
.scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; }
|
||||||
.artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; }
|
.artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; }
|
||||||
.artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; }
|
.artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; }
|
||||||
.scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; }
|
.scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; }
|
||||||
.noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; }
|
.noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; }
|
||||||
.noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; }
|
.noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; }
|
||||||
|
/* 随手附注与假协作光标定位在预览中,光标不拦截鼠标事件。 */
|
||||||
.looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; }
|
.looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; }
|
||||||
.cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; }
|
.cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; }
|
||||||
.cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; }
|
.cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; }
|
||||||
|
/* 预览页脚横排指示点与页码,aria-pressed 标示当前点,最后一项推到右侧。 */
|
||||||
.previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; }
|
.previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; }
|
||||||
.previewFooter > div { display: flex; gap: 5px; }
|
.previewFooter > div { display: flex; gap: 5px; }
|
||||||
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
|
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
|
||||||
.previewFooter button[aria-pressed="true"] { background: #879567; }
|
.previewFooter button[aria-pressed="true"] { background: #879567; }
|
||||||
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
|
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
|
||||||
|
/* 宣言横条用上下边线与两侧注记分隔首屏和后续内容。 */
|
||||||
.manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
.manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||||
.manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; }
|
.manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; }
|
||||||
.manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
|
.manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
|
||||||
.manifestoStrip p span { color: #9ba28e; }
|
.manifestoStrip p span { color: #9ba28e; }
|
||||||
|
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
|
||||||
.space { max-width: 1320px; margin: auto; padding: 105px 48px; }
|
.space { max-width: 1320px; margin: auto; padding: 105px 48px; }
|
||||||
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; }
|
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; }
|
||||||
.sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; }
|
.sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; }
|
||||||
.sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; }
|
.sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; }
|
||||||
.featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
.featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
||||||
.featureGrid article { min-width: 0; }
|
.featureGrid article { min-width: 0; }
|
||||||
|
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
|
||||||
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
|
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
|
||||||
|
/* 收集示例用轻微旋转和水平位移错开三条素材,保持纵向居中。 */
|
||||||
.collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
|
.collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
|
||||||
.collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; }
|
.collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; }
|
||||||
.collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; }
|
.collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; }
|
||||||
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
|
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
|
||||||
.collectArt svg { width: 14px; }
|
.collectArt svg { width: 14px; }
|
||||||
|
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
|
||||||
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
|
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
|
||||||
.connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; }
|
.connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; }
|
||||||
.connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; }
|
.connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; }
|
||||||
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
|
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
|
||||||
|
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
|
||||||
.createArt { display: grid; place-items: center; }
|
.createArt { display: grid; place-items: center; }
|
||||||
.createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; }
|
.createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; }
|
||||||
.createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; }
|
.createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; }
|
||||||
.createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; }
|
.createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; }
|
||||||
.editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; }
|
.editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; }
|
||||||
|
/* 编号、功能标题与正文使用逐级字号,正文行高便于多行阅读。 */
|
||||||
.featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; }
|
.featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; }
|
||||||
.featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; }
|
.featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; }
|
||||||
.featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; }
|
.featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; }
|
||||||
|
/* 工作流区域用浅底与上下边线分隔,两列分别承载引语和步骤。 */
|
||||||
.workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); }
|
.workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); }
|
||||||
|
/* 引号绝对定位在文案旁,标题自身保持相对定位,签名字体与正文区分。 */
|
||||||
.quote { position: relative; }
|
.quote { position: relative; }
|
||||||
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
|
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
|
||||||
.quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
|
.quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
|
||||||
.quote em { display: block; font-style: normal; }
|
.quote em { display: block; font-style: normal; }
|
||||||
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
|
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
|
||||||
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
|
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
|
||||||
|
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
|
||||||
.workflowList { display: flex; flex-direction: column; justify-content: center; }
|
.workflowList { display: flex; flex-direction: column; justify-content: center; }
|
||||||
.workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
|
.workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
|
||||||
.workflowList > div:first-child { padding-top: 0; }
|
.workflowList > div:first-child { padding-top: 0; }
|
||||||
@@ -97,11 +123,13 @@
|
|||||||
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
|
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
|
||||||
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
|
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
|
||||||
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
|
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
|
||||||
|
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;展开内容是否存在由组件决定。 */
|
||||||
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
|
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
|
||||||
.faqItem { border-bottom: 1px solid var(--line); }
|
.faqItem { border-bottom: 1px solid var(--line); }
|
||||||
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
|
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
|
||||||
.faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; }
|
.faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; }
|
||||||
.faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; }
|
.faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; }
|
||||||
|
/* 收尾 CTA 居中,星形装饰绝对定位;页脚内容横排并用 auto margin 推开链接。 */
|
||||||
.closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; }
|
.closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; }
|
||||||
.closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
|
.closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
|
||||||
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
|
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
|
||||||
@@ -110,8 +138,11 @@
|
|||||||
.footer > p { font-size: 11px; color: #a1a992; }
|
.footer > p { font-size: 11px; color: #a1a992; }
|
||||||
.footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; }
|
.footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; }
|
||||||
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
|
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
|
||||||
|
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
|
||||||
.mobileBreak { display: none; }
|
.mobileBreak { display: none; }
|
||||||
|
/* 大屏仅增大首屏上下留白。 */
|
||||||
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
|
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
|
||||||
|
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
|
||||||
@media (max-width: 1050px) {
|
@media (max-width: 1050px) {
|
||||||
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
|
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
|
||||||
.hero { gap: 26px; }
|
.hero { gap: 26px; }
|
||||||
@@ -124,6 +155,7 @@
|
|||||||
.featureGrid { gap: 20px; }
|
.featureGrid { gap: 20px; }
|
||||||
.faq { gap: 45px; }
|
.faq { gap: 45px; }
|
||||||
}
|
}
|
||||||
|
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
|
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
|
||||||
.nav nav { display: none; }
|
.nav nav { display: none; }
|
||||||
@@ -159,6 +191,7 @@
|
|||||||
.footer > p { display: none; }
|
.footer > p { display: none; }
|
||||||
.footer > span { flex-basis: 100%; }
|
.footer > span { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
|
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.page :global(.brand small) { display: none; }
|
.page :global(.brand small) { display: none; }
|
||||||
.hero { padding-left: 20px; padding-right: 20px; }
|
.hero { padding-left: 20px; padding-right: 20px; }
|
||||||
@@ -170,4 +203,5 @@
|
|||||||
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
|
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
|
||||||
.noteTag { font-size: 7px; padding: 2px; }
|
.noteTag { font-size: 7px; padding: 2px; }
|
||||||
}
|
}
|
||||||
|
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function LandingPage() {
|
|||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
|
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
|
||||||
const [faq, setFaq] = useState<number | null>(null);
|
const [faq, setFaq] = useState<number | null>(null);
|
||||||
|
// FAQ 的 hidden 只切换可见性,问答节点仍保留在 DOM;页面没有请求型加载或异步清理任务。
|
||||||
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
|
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
|
||||||
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
|
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
|
||||||
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
|
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const FeedbackContext = createContext(false);
|
|||||||
|
|
||||||
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
|
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
|
||||||
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
|
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
|
||||||
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
|
* children正常透传,本组件不订阅文档、队列或服务端状态。
|
||||||
|
* visibleToasts 限制同时可见数量,不是业务事件队列容量;通知创建/撤销仍通过通知适配器调用。 */
|
||||||
export function AppFeedback({ children }: { children: ReactNode }) {
|
export function AppFeedback({ children }: { children: ReactNode }) {
|
||||||
return <FeedbackContext.Provider value={true}>
|
return <FeedbackContext.Provider value={true}>
|
||||||
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface ButtonProps
|
|||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
|
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
|
||||||
|
// asChild 不把链接转换成原生按钮:disabled 属性本身不保证链接无法导航,调用方须按宿主处理行为。
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
|
|
||||||
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
|
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
|
||||||
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
|
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
|
||||||
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
|
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。
|
||||||
|
* 剩余 props 只传 Content;当前调用没有把 Overlay 的属性入口一并暴露给 DialogContent。 */
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
|
||||||
|
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
|
||||||
.button {
|
.button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -13,10 +15,13 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
/* SVG 同时约束宽高与 flex-basis,防止图标在文字/布局挤压下变形。 */
|
||||||
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
|
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
|
||||||
|
/* 悬停填底、键盘可见焦点描边、禁用态降低透明度并提示不可操作。 */
|
||||||
.button:hover { background: #e9e7e3; }
|
.button:hover { background: #e9e7e3; }
|
||||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* tooltip 使用高层级深色面板与有限宽度;arrow 颜色与面板一致。 */
|
||||||
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
|
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
|
||||||
.arrow { fill: #49372e; }
|
.arrow { fill: #49372e; }
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "chi
|
|||||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||||
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
||||||
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
|
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
|
||||||
|
// 后置 ...props 可以覆盖 aria-label 等同名属性;label 是默认名称,最终 DOM 语义由调用者共同负责。
|
||||||
const feedback = useContext(FeedbackContext);
|
const feedback = useContext(FeedbackContext);
|
||||||
const button = (
|
const button = (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
|
||||||
|
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
|
||||||
.surface {
|
.surface {
|
||||||
--toolbar-icon-size: 20px;
|
--toolbar-icon-size: 20px;
|
||||||
--toolbar-control-width: 32px;
|
--toolbar-control-width: 32px;
|
||||||
@@ -18,5 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Text in the view controls already creates wider visual spacing. */
|
/* Text in the view controls already creates wider visual spacing. */
|
||||||
|
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
|
||||||
.relaxed { column-gap: 6px; padding-inline: 6px; }
|
.relaxed { column-gap: 6px; padding-inline: 6px; }
|
||||||
|
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
|
||||||
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
|
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
|||||||
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
|
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// busy 是本次 render 的状态值,限制后续渲染后的重复提交;不是同步 ref 互斥或持久操作幂等键。
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
const data = new FormData(event.currentTarget);
|
const data = new FormData(event.currentTarget);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -38,6 +39,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
|||||||
if (settings) {
|
if (settings) {
|
||||||
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
|
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
|
||||||
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
|
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
|
||||||
|
// 宿主回调也在 try 内;若回调抛错,会显示失败,但已成功的服务端修改不会因此回滚。
|
||||||
onProfile(updated);
|
onProfile(updated);
|
||||||
} else {
|
} else {
|
||||||
const name = String(data.get("name")).trim();
|
const name = String(data.get("name")).trim();
|
||||||
@@ -49,6 +51,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求没有本地 AbortController/结果代次标记;受控 Dialog 阻止普通关闭,父级强制卸载仍由宿主管理。
|
||||||
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
|
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
|
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const ActiveBoardContext = createContext<{
|
|||||||
|
|
||||||
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
|
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
|
||||||
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
||||||
|
// Provider 本身不监听路由/登出,也不推断哪条异步结果较新;发布、清空及身份核对由调用方负责。
|
||||||
const [board, setBoard] = useState<ActiveBoard>(null);
|
const [board, setBoard] = useState<ActiveBoard>(null);
|
||||||
const value = useMemo(() => ({ board, setBoard }), [board]);
|
const value = useMemo(() => ({ board, setBoard }), [board]);
|
||||||
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import styles from "./workspace.module.css";
|
|||||||
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
|
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
|
||||||
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
|
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
|
||||||
const [closed, setClosed] = useState(new Set<string>());
|
const [closed, setClosed] = useState(new Set<string>());
|
||||||
|
// memo 按 boards 引用重算;宿主更新条目须提供新数组,不能只原地改标题/父链再期待重新派生。
|
||||||
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
|
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
|
||||||
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
|
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,6 +23,7 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
|
|||||||
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
|
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
|
||||||
function render(items: BoardBranch[], depth = 0) {
|
function render(items: BoardBranch[], depth = 0) {
|
||||||
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
|
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
|
||||||
|
// 搜索中的切换只修改 closed 记录,当前仍强制展开;清掉搜索后才看到用户折叠记录的效果。
|
||||||
const expanded = !!query.trim() || !closed.has(board.id);
|
const expanded = !!query.trim() || !closed.has(board.id);
|
||||||
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
|
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
|
||||||
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
|
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const Renderers=createContext(defaultCanvasRenderers);
|
|||||||
export function useCanvasRenderers(){return useContext(Renderers);}
|
export function useCanvasRenderers(){return useContext(Renderers);}
|
||||||
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
|
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
|
||||||
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
|
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
|
||||||
|
// 惰性初始化只装配对象/工厂,不在 render 直接打开项目;实际账户绑定和持久化租约由后续调用取得。
|
||||||
const [{host,renderers}] = useState(() => {
|
const [{host,renderers}] = useState(() => {
|
||||||
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
|
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
|
||||||
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
|
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
|
||||||
@@ -51,6 +52,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
|
|||||||
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
|
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generation = ++lifetime.current;
|
const generation = ++lifetime.current;
|
||||||
|
// cleanup 不等待 close 完成,也不主动重试;异步失败经 host 的错误通道和此处 catch 报告。
|
||||||
// Strict Mode replays effects synchronously; only a real detach retires the host.
|
// Strict Mode replays effects synchronously; only a real detach retires the host.
|
||||||
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
|
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
|
||||||
}, [host]);
|
}, [host]);
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
/* EditableTitle 的只读标题/输入框切换样式;壳体作为错误提示的定位参照,网络和提交状态由组件控制。 */
|
||||||
|
/* 相对定位保留标题所在行,绝对定位错误框不会把工具栏向下撑开。 */
|
||||||
.shell { position: relative; display: flex; align-items: center; }
|
.shell { position: relative; display: flex; align-items: center; }
|
||||||
|
/* 两种状态共用高度、字体和透明边框,降低进入编辑时的尺寸跳变。 */
|
||||||
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
|
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
|
||||||
|
/* 非编辑标题单行省略;悬停仅作用于可用按钮,选中态由 data-selected 控制。 */
|
||||||
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
|
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
|
||||||
.title:hover:not(:disabled) { background: #f0ece6; }
|
.title:hover:not(:disabled) { background: #f0ece6; }
|
||||||
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
||||||
.title:disabled { cursor: default; }
|
.title:disabled { cursor: default; }
|
||||||
|
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
|
||||||
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
||||||
.input[readonly] { opacity: 0.65; }
|
.input[readonly] { opacity: 0.65; }
|
||||||
|
/* 错误框显示在标题下方,并以视口宽度限制最大宽度;长说明允许换行。 */
|
||||||
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
|
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
|||||||
cancelled.current = true;
|
cancelled.current = true;
|
||||||
setError("");
|
setError("");
|
||||||
setMode("selected");
|
setMode("selected");
|
||||||
|
// 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。
|
||||||
requestAnimationFrame(() => button.current?.focus());
|
requestAnimationFrame(() => button.current?.focus());
|
||||||
}
|
}
|
||||||
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
|
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
|
||||||
@@ -70,6 +71,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
|||||||
setMode("saving");
|
setMode("saving");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
// onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。
|
||||||
await onSave(name);
|
await onSave(name);
|
||||||
cancelled.current = true;
|
cancelled.current = true;
|
||||||
setMode("idle");
|
setMode("idle");
|
||||||
@@ -78,6 +80,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
|||||||
setMode("editing");
|
setMode("editing");
|
||||||
} finally { pending.current = false; }
|
} finally { pending.current = false; }
|
||||||
}
|
}
|
||||||
|
// title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。
|
||||||
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
|
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
|
||||||
return (
|
return (
|
||||||
<div ref={shell} className={`${styles.shell} ${className}`}>
|
<div ref={shell} className={`${styles.shell} ${className}`}>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
|||||||
// latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。
|
// latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。
|
||||||
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
||||||
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
|
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
|
||||||
|
// 此错误显示只检查页面 active;它不像结果采用分支那样再核对 currentIdentity。
|
||||||
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
||||||
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
|
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
|
||||||
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
||||||
@@ -68,6 +69,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
|||||||
function pump(){
|
function pump(){
|
||||||
if(pumping)return pumping;
|
if(pumping)return pumping;
|
||||||
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
||||||
|
// finally 清空运行句柄,再对等待槽续泵;latest 只留最近预览,并非逐块持久操作队列。
|
||||||
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
|||||||
catch(error){reportBackground(error);}
|
catch(error){reportBackground(error);}
|
||||||
}
|
}
|
||||||
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
|
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
|
||||||
|
// 任一次泵拒绝会沿外层 catch 显示错误,不继续把完整结果冒充已经成功采用。
|
||||||
while(pumping)await pumping;
|
while(pumping)await pumping;
|
||||||
if(!active||!currentIdentity?.())return;
|
if(!active||!currentIdentity?.())return;
|
||||||
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
|
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
|
||||||
@@ -101,12 +104,14 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
|||||||
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
||||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||||
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
|
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
|
||||||
|
// route 只包含两个路径参数,不包含 demo/retry/authEpoch;同路由这些值变化时由 effect 清状态和取消护栏处理。
|
||||||
const current = loaded?.route === route ? loaded : null;
|
const current = loaded?.route === route ? loaded : null;
|
||||||
const currentPreview=preview?.route===route?preview:null;
|
const currentPreview=preview?.route===route?preview:null;
|
||||||
const board = current?.board??currentPreview?.board;
|
const board = current?.board??currentPreview?.board;
|
||||||
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */
|
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
|
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
|
||||||
|
// 这是当前页面的无条件清空,不删除 runtime 或目录记录,也不是按 boardId 比对的条件撤回。
|
||||||
return () => setActiveBoard(null);
|
return () => setActiveBoard(null);
|
||||||
}, [board, workspaceId, setActiveBoard]);
|
}, [board, workspaceId, setActiveBoard]);
|
||||||
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
|
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
|
||||||
|
|||||||
@@ -7,13 +7,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import styles from "./workspace.module.css";
|
import styles from "./workspace.module.css";
|
||||||
|
|
||||||
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
|
/** 只记住最近一次失败URL;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。
|
||||||
|
* 换 URL 可以再试,若之后回到较早失败的 URL 也可能重试;不是永久失败地址集合。 */
|
||||||
export function UserAvatar({ name, url, large = false }: {
|
export function UserAvatar({ name, url, large = false }: {
|
||||||
name: string;
|
name: string;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||||
|
// fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。
|
||||||
const showImage = Boolean(url && url !== failedUrl);
|
const showImage = Boolean(url && url !== failedUrl);
|
||||||
return (
|
return (
|
||||||
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
|
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
|
||||||
|
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
|
||||||
.shell {
|
.shell {
|
||||||
--sidebar-width: 270px;
|
--sidebar-width: 270px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5,6 +7,7 @@
|
|||||||
background: #fffefb;
|
background: #fffefb;
|
||||||
color: #37352f;
|
color: #37352f;
|
||||||
}
|
}
|
||||||
|
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
|
||||||
.sidebarSlot {
|
.sidebarSlot {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -29,30 +33,38 @@
|
|||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
color: #37352f;
|
color: #37352f;
|
||||||
}
|
}
|
||||||
|
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
|
||||||
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
|
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
|
||||||
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; }
|
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; }
|
||||||
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
|
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
|
||||||
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
|
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
|
||||||
.search input::placeholder { color: #9a9690; }
|
.search input::placeholder { color: #9a9690; }
|
||||||
|
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
|
||||||
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
||||||
|
/* 首页与目录链接共用行高/图标间距,长标题省略;aria-current 对当前页提供选中背景。 */
|
||||||
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
|
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
|
||||||
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
|
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
|
||||||
.pageLink { margin: 1px 0; }
|
.pageLink { margin: 1px 0; }
|
||||||
.pageLink svg { flex-shrink: 0; color: #87837c; }
|
.pageLink svg { flex-shrink: 0; color: #87837c; }
|
||||||
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
|
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
|
||||||
|
/* 分组名称、空态提示和错误分别排版;错误允许在任意位置折行。 */
|
||||||
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
||||||
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; }
|
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; }
|
||||||
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
|
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
|
||||||
|
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
|
||||||
.accountArea { margin-top: auto; padding-top: 6px; }
|
.accountArea { margin-top: auto; padding-top: 6px; }
|
||||||
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
|
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
|
||||||
.accountButton:hover { background: #edeae5; }
|
.accountButton:hover { background: #edeae5; }
|
||||||
|
/* 头像固定尺寸并裁成圆形,图片 object-fit:cover;缺图时容纳首字占位。 */
|
||||||
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
|
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
|
||||||
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
|
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
|
||||||
|
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
|
||||||
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
|
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
|
||||||
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.accountText small { display: none; }
|
.accountText small { display: none; }
|
||||||
|
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
|
||||||
.accountPopup {
|
.accountPopup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto auto 50px 8px;
|
inset: auto auto 50px 8px;
|
||||||
@@ -73,31 +85,40 @@
|
|||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
transform-origin: bottom left;
|
transform-origin: bottom left;
|
||||||
}
|
}
|
||||||
|
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
|
||||||
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
|
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
|
||||||
.accountPopup::backdrop { background: transparent; }
|
.accountPopup::backdrop { background: transparent; }
|
||||||
|
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
|
||||||
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
|
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
|
||||||
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
|
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
|
||||||
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
|
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
|
||||||
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.accountHeading small { color: #8b8780; font-size: 12px; }
|
.accountHeading small { color: #8b8780; font-size: 12px; }
|
||||||
|
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
|
||||||
.menuGroup { padding: 1px 0; }
|
.menuGroup { padding: 1px 0; }
|
||||||
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
|
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
|
||||||
.popupIdentity strong { font-size: 12px; font-weight: 500; }
|
.popupIdentity strong { font-size: 12px; font-weight: 500; }
|
||||||
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
|
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
|
||||||
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
|
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
|
||||||
|
/* 菜单项整行可点,文字可收缩,图标保持宽度;disabled 态显式降低透明度。 */
|
||||||
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
|
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
|
||||||
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.menuItem > svg { flex-shrink: 0; }
|
.menuItem > svg { flex-shrink: 0; }
|
||||||
.menuItem:hover { background: #f1f0ed; }
|
.menuItem:hover { background: #f1f0ed; }
|
||||||
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
|
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
/* 工作区小头像与新建/登出颜色区分,separator 分隔组而不引入额外布局容器。 */
|
||||||
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
|
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
|
||||||
.newWorkspace { color: #2383e2; }
|
.newWorkspace { color: #2383e2; }
|
||||||
.logout { color: #78746d; font-size: 12px; }
|
.logout { color: #78746d; font-size: 12px; }
|
||||||
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
|
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
|
||||||
|
/* 左上浮动条以 --floating-left 定位;侧栏展开时偏移到侧栏右侧,位置和宽度作 240ms 过渡。 */
|
||||||
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
|
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||||
|
/* 展开侧栏时使用实际受视口约束的侧栏宽度计算偏移;账户 popover 也继承同一偏移变量。 */
|
||||||
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
|
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
|
||||||
|
/* 浮动条错误绝对定位于条下方,账户设置面板则沿用对话框容器。 */
|
||||||
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
|
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
|
||||||
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
|
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
|
||||||
|
/* 设置表单纵向排布身份、字段及操作;提交按钮区分主动作,禁用时保留位置。 */
|
||||||
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
|
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
|
||||||
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
|
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
|
||||||
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
|
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
|
||||||
@@ -106,35 +127,48 @@
|
|||||||
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
|
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
|
||||||
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
|
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
|
||||||
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
/* 固定全视口画布置于底层;点阵背景与加载/错误状态由后续类分别承担。 */
|
||||||
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
|
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
|
||||||
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
|
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
|
||||||
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
|
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
|
||||||
|
/* 账户菜单从轻微下移/缩小过渡到正常位置与不透明状态。 */
|
||||||
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
|
/* 较窄视口将侧栏目标宽度收为 240px;仍受上方 max-width 约束。 */
|
||||||
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
|
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
|
||||||
|
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
|
||||||
|
|
||||||
|
/* 浮动条 logo 链接沿用 Toolbar 控件尺寸与键盘焦点样式,保证和旁边按钮对齐。 */
|
||||||
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; }
|
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; }
|
||||||
.logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
|
.logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
|
||||||
.logoLink:hover { background: #e9e7e3; }
|
.logoLink:hover { background: #e9e7e3; }
|
||||||
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* 浮动头像按钮同样使用共享尺寸;从浮动条打开的账户面板改为顶端定位。 */
|
||||||
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
|
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
|
||||||
.floatingAvatarButton:hover { background: #e9e7e3; }
|
.floatingAvatarButton:hover { background: #e9e7e3; }
|
||||||
|
/* clamp 把账户面板左边界限制在视口内;transform-origin 随顶部锚点改为左上。 */
|
||||||
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
|
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
|
||||||
|
|
||||||
|
/* 浮动头像采用图标尺寸,标题允许 flex 收缩但保留溢出区域给编辑错误提示。 */
|
||||||
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
|
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
|
||||||
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
|
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
|
||||||
|
/* 中等窄屏降低标题最大宽度,不改变标题内容或编辑状态。 */
|
||||||
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
||||||
|
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.floating[data-sidebar-collapsed="false"] .logoLink,
|
.floating[data-sidebar-collapsed="false"] .logoLink,
|
||||||
.floating[data-sidebar-collapsed="false"] .floatingTitle,
|
.floating[data-sidebar-collapsed="false"] .floatingTitle,
|
||||||
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
|
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
|
||||||
}
|
}
|
||||||
|
/* 按用户偏好关闭浮动条位移/宽度过渡。 */
|
||||||
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
|
||||||
|
|
||||||
|
/* 递归目录清除列表默认缩进,层级缩进由组件布局提供;行本身允许标题收缩。 */
|
||||||
.directoryList { list-style: none; padding: 0; margin: 0; }
|
.directoryList { list-style: none; padding: 0; margin: 0; }
|
||||||
.directoryRow { display: flex; align-items: center; min-width: 0; }
|
.directoryRow { display: flex; align-items: center; min-width: 0; }
|
||||||
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
|
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
|
||||||
|
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
|
||||||
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
|
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
|
||||||
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
|
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
|
||||||
.directoryToggle:hover { background: #eae7e2; }
|
.directoryToggle:hover { background: #eae7e2; }
|
||||||
|
|||||||
+4
-1
@@ -27,9 +27,11 @@ export async function api<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
// fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。
|
||||||
const res = await fetch("/api/foundry/" + path, {
|
const res = await fetch("/api/foundry/" + path, {
|
||||||
...options,
|
...options,
|
||||||
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
|
// FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。
|
||||||
|
// 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。
|
||||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||||
});
|
});
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
@@ -49,6 +51,7 @@ export async function api<T>(
|
|||||||
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
|
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
|
||||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
|
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
|
||||||
}
|
}
|
||||||
|
// 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
|
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|||||||
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
|
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
|
||||||
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
|
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
|
||||||
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
|
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
|
||||||
|
// Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。
|
||||||
const boards = new Map<string, Board>();
|
const boards = new Map<string, Board>();
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
@@ -28,6 +29,7 @@ export type BoardBranch = { board: Board; children: BoardBranch[] };
|
|||||||
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
|
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
|
||||||
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
|
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
|
||||||
export function boardDirectory(boards: Board[]): BoardBranch[] {
|
export function boardDirectory(boards: Board[]): BoardBranch[] {
|
||||||
|
// 重复 ID 采用最后一条数据、沿用首次插入顺序;分支节点是新对象,board 本身仍借用输入引用。
|
||||||
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
|
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
|
||||||
const roots: BoardBranch[] = [];
|
const roots: BoardBranch[] = [];
|
||||||
for (const entry of entries.values()) {
|
for (const entry of entries.values()) {
|
||||||
@@ -56,6 +58,7 @@ export function filterBoardDirectory(branches: BoardBranch[], query: string): Bo
|
|||||||
if (!term) return branches;
|
if (!term) return branches;
|
||||||
return branches.flatMap((branch) => {
|
return branches.flatMap((branch) => {
|
||||||
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
|
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
|
||||||
|
// 只重建需要裁剪 children 的祖先容器;直接命中的分支复用原对象,不深复制整棵树。
|
||||||
const children = filterBoardDirectory(branch.children, term);
|
const children = filterBoardDirectory(branch.children, term);
|
||||||
return children.length ? [{ ...branch, children }] : [];
|
return children.length ? [{ ...branch, children }] : [];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Reg
|
|||||||
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
|
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
|
||||||
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
|
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
|
||||||
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
|
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
|
||||||
|
// 键不含 rootId:独占迁移锁会暂时排斥同账户/工作区所有共享旧编辑租约,而非仅目标树。
|
||||||
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
|
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
|
||||||
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
|
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
|
||||||
function ports(options:BrowserScope){
|
function ports(options:BrowserScope){
|
||||||
@@ -30,6 +31,7 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
|
|||||||
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
|
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
|
||||||
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
|
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
|
||||||
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
|
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
|
||||||
|
// signal 未传给 locks.request;取消在回调授予时检查,持锁后通过 abort 监听释放。
|
||||||
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
|
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
|
||||||
try{
|
try{
|
||||||
check(options);if(!lock)throw fault('migration_busy');
|
check(options);if(!lock)throw fault('migration_busy');
|
||||||
@@ -56,6 +58,7 @@ export function createBrowserCanvasMigration(options:BrowserScope&{registry:Regi
|
|||||||
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
|
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
|
||||||
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
|
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
|
||||||
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
|
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
|
||||||
|
// work 的 Promise 整体保持独占租约;拒绝同样交还锁,pending 记录不会随锁释放而自动删除。
|
||||||
check(options);if(!lock)throw fault('migration_busy');return work();
|
check(options);if(!lock)throw fault('migration_busy');return work();
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
|
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
|
||||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||||
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
|
/** 旧上传接口的素材响应形状;返回前检查 ID、URL 的源站/路径/查询和尺寸,不完整验证所有字段。 */
|
||||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||||
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
|
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
|
||||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||||
@@ -12,6 +12,7 @@ export const ASSET_LIMIT = 20 * 1024 * 1024;
|
|||||||
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
|
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
|
||||||
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
|
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
|
||||||
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
||||||
|
// 文件大小是本地发送前检查;不在这里解码图片、计算内容摘要或确认 mime_type。
|
||||||
const form = new FormData(); form.append("image", file, name);
|
const form = new FormData(); form.append("image", file, name);
|
||||||
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
|
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
|
||||||
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
||||||
@@ -25,5 +26,6 @@ export async function uploadCanvasImage(request: Request, workspace: string, fil
|
|||||||
let url: URL;
|
let url: URL;
|
||||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||||
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
|
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
|
||||||
|
// bytes/mime_type 未在此逐项核验,URL hash/用户名密码也未另设拒绝条件;不能视为新版资源权威证明。
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
|
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
|
||||||
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
|
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
|
||||||
|
* 本文件仅定义 TypeScript 形状,不运行默认值/迁移/编辑行为;投影器负责从标准 element 生成这些显示字段。
|
||||||
*/
|
*/
|
||||||
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
|
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
|
||||||
type Point={x:number;y:number};
|
type Point={x:number;y:number};
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {compatConnector} from './connectors.ts';
|
|||||||
/** Conservative cubic control hull + half stroke, in the displayed scene space.
|
/** Conservative cubic control hull + half stroke, in the displayed scene space.
|
||||||
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
|
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
|
||||||
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
|
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
|
||||||
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
|
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。
|
||||||
|
* maxEdges 限访问的边数,maxSegments 同时作单边与总段数限;存在待查端点时仍遍历所有 projected nodes。
|
||||||
|
* 几何回调同步执行且没有时间抢占,回调产出数组前的成本不由预算控制;输入须属于同一有效显示帧。 */
|
||||||
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
|
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
|
||||||
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
|
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
|
||||||
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
|
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
|
||||||
@@ -29,21 +31,25 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
|
|||||||
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
||||||
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
|
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
|
||||||
const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
|
const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
|
||||||
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。
|
// 签名复制锚点/端点数值/transform,但 record 与 edge 借用原不可变引用,缓存不深捕获它们。
|
||||||
|
// 外部几何服务/协议配置不在签名内,须通过下方 geometryRevision 或 registry 对象变化失效。
|
||||||
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
|
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
|
||||||
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。
|
// 锚点与基础输入每轮仍重新求取;签名相等才复用曲线范围/段数,跳过 segments/stroke 计算。
|
||||||
|
// 空 region 可表示笔画未超原生范围或空 segments,不能将其误当“整条边没有显示内容”。
|
||||||
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
|
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
|
||||||
computed++;
|
computed++;
|
||||||
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
|
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
|
||||||
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
|
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
|
||||||
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。
|
// 非空段数超过本次剩余预算则追加全局 paint_budget 并终止后续边;空数组仍要求有效 stroke。
|
||||||
|
// 空数组缓存0段/无region,属本几何协议声明无笔画,不误标 unknown。
|
||||||
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
|
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
|
||||||
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
|
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
|
||||||
// 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。
|
// 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。
|
||||||
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
|
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
|
||||||
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
|
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
|
||||||
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
|
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
|
||||||
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。
|
// 原生范围取两端节点未旋转矩形的联合 AABB;曲线控制点凸包+半线宽 AABB 超出时才添整块补充范围。
|
||||||
|
// 这是保守估计而非曲线精确极值,允许额外保留可见候选;不计算命中距离或直接控制 renderer 挂载。
|
||||||
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
|
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
|
||||||
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
|
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
|
||||||
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
|
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
|
||||||
@@ -56,11 +62,15 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
|
|||||||
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
|
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
|
||||||
/** Host revision invalidates geometry services whose external configuration changes. */
|
/** Host revision invalidates geometry services whose external configuration changes. */
|
||||||
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
|
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
|
||||||
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
|
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。
|
||||||
|
* 每视图独立实例;缓存按 registry 对象/project/canvas/revision 隔离,不另核 editor owner。
|
||||||
|
* cache 保留边几何结果,index 存本轮额外区域;超过预算的其它边可暂留缓存但不进入本轮 regions。 */
|
||||||
export function createConnectionPaintIndex(){
|
export function createConnectionPaintIndex(){
|
||||||
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
|
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
|
||||||
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
|
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
|
||||||
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
|
* unknown 中元素 ID 表示该边无法解释,字符串 paint_budget 表示余下查询不完整,不能据空 regions 判不可见。
|
||||||
|
* query 是可变 index 的方法,旧返回对象也查最新网格,unknown 则是本轮冻结列表。
|
||||||
|
* computed 计通过锚点/变换检查后未命中的几何尝试(可随后失败),不等于成功区域数;clear 不重置累计值。 */
|
||||||
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
|
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
|
||||||
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
|
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
|
||||||
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
|
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometr
|
|||||||
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
|
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
|
||||||
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
|
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
|
||||||
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
|
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
|
||||||
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */
|
* 找到具名 port 时,同 direction 的 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。
|
||||||
|
* 即使 fallback hint 选择了另一侧,已识别 port 仍按高度做 y 偏移;这是旧布局协议,不适用于原生 Handle。
|
||||||
|
* nodeConnector 先负责节点边界/旋转,下面直接叠加 y 偏移,不再把这段偏移当局部向量旋转。 */
|
||||||
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
|
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
|
||||||
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
|
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
|
||||||
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
|
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
|
||||||
@@ -21,7 +23,9 @@ export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:numbe
|
|||||||
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
||||||
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
|
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
|
||||||
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
|
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
|
||||||
* 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */
|
* source 仅查 source 列表;target 按 target 再 source 顺序查,匹配 loose 模式。
|
||||||
|
* 非空 handleId 精确查找,空字符串/null/undefined 取首项;无匹配或未测量返回 undefined。
|
||||||
|
* 假定 positionAbsolute 与 Handle 尺寸采用同一场景单位;直接叠加,不在这里验证有限值或再次处理节点旋转。 */
|
||||||
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
|
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
|
||||||
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
|
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
|
||||||
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
|
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export type ElementSourcePorts={
|
|||||||
};
|
};
|
||||||
/** One upstream subscription, ID buckets and cached lookup values, never another document.
|
/** One upstream subscription, ID buckets and cached lookup values, never another document.
|
||||||
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
|
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
|
||||||
/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */
|
/** buckets 按 elementId 共享上游查询结果/观察器,每个 forElement 另有即时读取缓存。
|
||||||
|
* 首次 subscribe 才接上游,全部桶退订才释放 previous 根引用;创建源或直接读不持有上游租约。
|
||||||
|
* 依赖不可变快照与完整 Commit.changes 合同,不从队列/网络结果猜测元素是否已改变。 */
|
||||||
export function createElementSources(ports:ElementSourcePorts){
|
export function createElementSources(ports:ElementSourcePorts){
|
||||||
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
|
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
|
||||||
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
|
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
|
||||||
@@ -28,11 +30,13 @@ export function createElementSources(ports:ElementSourcePorts){
|
|||||||
const before=previous!;previous=next;
|
const before=previous!;previous=next;
|
||||||
const ids=new Set<Id>();
|
const ids=new Set<Id>();
|
||||||
if(before.document!==next.document){
|
if(before.document!==next.document){
|
||||||
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。
|
// 显式变化只路由 field.target.id / element.id,不按 canvas 变更自动扩散到全体子元素。
|
||||||
|
// 文档替换且无 changes 时复查全部已订阅桶;路由准确性依赖发布者提供完整变更及 lookup 的依赖合同。
|
||||||
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
|
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
|
||||||
else for(const id of buckets.keys())ids.add(id);
|
else for(const id of buckets.keys())ids.add(id);
|
||||||
}
|
}
|
||||||
// loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。
|
// loading 对象或其读取基线变动可改变 absent/unloaded 判定,因此复查非 present 桶。
|
||||||
|
// 不将任意 runtime 更新都当元素变化;failed 桶也只有进入本次候选 IDs 才会在此重新查询。
|
||||||
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
|
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
|
||||||
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
|
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
|
||||||
const notifications=new Set<()=>void>();
|
const notifications=new Set<()=>void>();
|
||||||
@@ -50,7 +54,8 @@ export function createElementSources(ports:ElementSourcePorts){
|
|||||||
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
|
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
|
||||||
let value:ElementLookup|undefined;
|
let value:ElementLookup|undefined;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */
|
/** 每次查询当前根,用 equal 保持等价 ElementLookup 的局部引用;读取不改共享桶通知基线。
|
||||||
|
* 不深复制查询值,lookup 必须返回适合不可变使用的结果;异常直接抛给调用方读取边界。 */
|
||||||
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
|
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
|
||||||
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
|
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
|
||||||
subscribe(notify:()=>void){
|
subscribe(notify:()=>void){
|
||||||
@@ -59,7 +64,8 @@ export function createElementSources(ports:ElementSourcePorts){
|
|||||||
const listener=()=>notify();bucket.listeners.add(listener);
|
const listener=()=>notify();bucket.listeners.add(listener);
|
||||||
// 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。
|
// 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。
|
||||||
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
|
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
|
||||||
// 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。
|
// 最后一个桶清空才 stop 上游并释放 previous;同回调的多次订阅因 listener 包装仍分别退订。
|
||||||
|
// stop 异常不被捕获,后续状态重置不保证执行;宿主的取消订阅端口应不抛错。
|
||||||
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
|
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,23 +6,28 @@ import type {ExternalSource} from './selectors.ts';
|
|||||||
/** Presentation invalidations only. Commands, history and network work never wait here. */
|
/** Presentation invalidations only. Commands, history and network work never wait here. */
|
||||||
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
|
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
|
||||||
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
|
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
|
||||||
/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */
|
/** 创建一条可手动 flush 的通知队列;Set 按键身份去重,并保留首次插入顺序。
|
||||||
|
* clock/publish 由宿主实现,不读取 DOM/模型;调度器异常原样抛出,不另设重试或全局错误处理。 */
|
||||||
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
|
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
|
||||||
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
|
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
|
||||||
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */
|
/** 同步排空:先撤当前调度并换新 Set,再 publish,回调中的 invalidate 留给下一批。
|
||||||
|
* batch 以 ReadonlySet 类型借出但未运行时冻结,与新 dirty 已分离;publish 抛错不把本批自动放回队列。 */
|
||||||
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
|
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */
|
/** 逐项加入 dirty,至多安排一个帧回调;pending 对象身份防旧回调误刷后来一批。
|
||||||
|
* request 抛错会清 scheduled,但保留 dirty 供后续 invalidate/flush 重试;不自动再 request。 */
|
||||||
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
|
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
|
||||||
flush,
|
flush,
|
||||||
/** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */
|
/** 标记 disposed 后尽力取消帧并清 dirty;后续 invalidate/flush 无效,不替上游关闭 editor。
|
||||||
|
* 这里不捕获 clock.cancel 异常,宿主取消端口须可靠清理;重复 dispose 不会重试一次失败取消。 */
|
||||||
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
|
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
|
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
|
||||||
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
|
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
|
||||||
* 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */
|
* 上游标 immediate 时先 invalidate 再同步 flush,合并的旧失效也一起通知;读取从不等待帧。
|
||||||
|
* 同一工厂的不同订阅不共享队列;退订函数每次都会调用上游 stop,幂等性依赖该 stop 合同。 */
|
||||||
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
|
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
|
||||||
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
|
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
|
||||||
const queue=createFrameQueue<number>(clock,()=>notify());
|
const queue=createFrameQueue<number>(clock,()=>notify());
|
||||||
|
|||||||
@@ -12,21 +12,26 @@ const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.wi
|
|||||||
/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */
|
/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */
|
||||||
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
|
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
|
||||||
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
|
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
|
||||||
/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */
|
/** 一个显示上下文使用一份缓存:三个 Map 分别缓存节点几何、子 canvas 变换和连线路径。
|
||||||
|
* 调用方须按 begin→若干 node/child/edge→end 使用;这里不校验调用配对,也不提供失败回滚/并发帧隔离。
|
||||||
|
* 累计计数只在实际重算后增加,不是文档版本或当前命中次数;返回投影值冻结,键只保留所需输入。 */
|
||||||
export function createGeometryProjectionCache(){
|
export function createGeometryProjectionCache(){
|
||||||
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
|
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
|
||||||
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
|
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
|
||||||
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
|
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */
|
/** scope 按对象身份、project/canvas 按 ID 隔离;任一变化清三张表,累计计数不清。
|
||||||
|
* 每轮重建 seen,随后命中缓存也记一次访问;begin 不自行遍历文档或判断元素仍可见。 */
|
||||||
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
|
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
|
||||||
/** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */
|
/** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */
|
||||||
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
|
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
|
||||||
/** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */
|
/** 分区入口几何+父变换+contentScale/unitScale 生成内部 canvas→场景变换,单位变化也使缓存失效。
|
||||||
|
* 不含 collapsed 等显示决策,是否调用 child 由投影器决定;使用核心 childCanvasTransform 解释单位。 */
|
||||||
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
|
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
|
||||||
/** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
|
/** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
|
||||||
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
|
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
|
||||||
/** 删除本轮未访问的缓存项,避免浏览多个分区后无限保留旧节点和路径。 */
|
/** 扫描三张缓存表,删除本轮未访问项;保留规则按调用访问,不等于元素已经从文档删除。
|
||||||
|
* 调用方若提前抛错且不执行 end,未访问旧项可暂留至下次完整清理,缓存不是业务恢复事实。 */
|
||||||
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
|
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
|
||||||
/** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */
|
/** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */
|
||||||
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
|
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFin
|
|||||||
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
|
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
|
||||||
/** Query the captured display frame. It neither edits nor replaces the native input router. */
|
/** Query the captured display frame. It neither edits nor replaces the native input router. */
|
||||||
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
|
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
|
||||||
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */
|
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。
|
||||||
|
* 同步消费借入的 projection/query/services,不捕获另一份文档;几何服务须按此显示帧返回同坐标系的结果。
|
||||||
|
* maxCandidates 约束节点精测+边访问,maxCurveSteps 由全部边共享,maxSegments 则是每边上限;
|
||||||
|
* 前置 Map/层序/空间候选收集和用户几何回调不被这些计数抢占,不能据此承诺总耗时硬上限。 */
|
||||||
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
|
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
|
||||||
// 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。
|
// 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。
|
||||||
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
|
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
|
||||||
@@ -38,34 +41,43 @@ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries
|
|||||||
// 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。
|
// 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。
|
||||||
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
|
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
|
||||||
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
|
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
|
||||||
// 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。
|
// 先收集 previewIds,再逐 canvas 反变换查局部索引,以 Set 合并;真正精测前会排除未出现在投影中的 ID。
|
||||||
|
// 索引几何未随瞬态 preview 改动时,renderer 必须补 previewIds,不能只靠原位置的空间候选。
|
||||||
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
|
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
|
||||||
/** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */
|
/** 从元素真实 owner 沿 parent 回到当前显示根,反转成根→owner 路径;不是修改元素归属。
|
||||||
|
* 每条命中都生成显示路径,循环/父关系断裂作为该目标诊断;viewSessionId 只沿用调用值,不查询 session 注册表。 */
|
||||||
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
|
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
|
||||||
/** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */
|
/** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */
|
||||||
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
|
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
|
||||||
// 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。
|
// 节点精测只处理投影中实际存在的候选;unknown/fallback/非 node 几何协议统一用矩形显示外壳。
|
||||||
|
// confirmed 仅证明采用的显示几何命中,不宣称已经理解未知组件原始轮廓。目标回调须按 Error 风格抛错。
|
||||||
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
|
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
|
||||||
// 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。
|
// bounds 在此用于合法性检查,最终命中由 hit 返回布尔值;不能把通过 bounds 检查当成已经命中。
|
||||||
|
// geometry 使用 node 的显示尺寸/位置(含预览)反算,element 仍是同帧文档记录,容差换算为 owner 局部单位。
|
||||||
const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');
|
const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');
|
||||||
const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'});
|
const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'});
|
||||||
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
||||||
// 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。
|
// 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。
|
||||||
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
||||||
// 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。
|
// 节点精测在先,连线随后遍历 projection.edges(不再做空间粗筛),两者共用 work 预算。
|
||||||
|
// 单边解析显示端点/画笔失败记诊断,继续其它边;某些边因节点预算已耗尽可能尚未访问。
|
||||||
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
|
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
|
||||||
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
|
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
|
||||||
// renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。
|
// 自定义 edgeGeometry 非 null/undefined 的结果优先(包括空数组),否则调用已知 connection 协议。
|
||||||
|
// 先检查每段4个有限点及每边段数,再求距;回调本身仍同步执行,没有超时或抢占。
|
||||||
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
|
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
|
||||||
// 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。
|
// strokeWidth 同样优先服务回调,再用组件协议,要求是场景单位的有限非负数。
|
||||||
|
// 有效点击半径=场景容差+线宽/2;不能把模型局部宽度或屏幕像素宽度未经换算直接送进来。
|
||||||
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
|
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
|
||||||
// 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。
|
// 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。
|
||||||
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
|
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
|
||||||
// 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。
|
// 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。
|
||||||
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
|
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
|
||||||
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
||||||
// 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。
|
// 只核当前 projection.transforms 覆盖的显示 canvas 是否完整;不推断折叠内部的未显示元素。
|
||||||
|
// 命中列表非空仍可能不完整,diagnostics 汇总几何解释/预算/精度与模型覆盖问题。
|
||||||
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
|
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
|
||||||
// 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。
|
// 排序:layer 降序→同层 node 优先→文档层序倒序,不以 confirmed/possible 抢改显示层级顺序。
|
||||||
|
// complete 仅等于本次 diagnostics 为空;消费者仍需理解命中 certainty,函数不改变 selection 或手势。
|
||||||
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
|
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
|
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
|
||||||
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
|
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
|
||||||
|
* 此入口只重导出,不创建 view、订阅或缓存实例;scene coordinator 与分桶源的内部接线不在此集中公开。
|
||||||
*/
|
*/
|
||||||
/** Portable scene projection and hit queries: no React or React Flow imports. */
|
/** Portable scene projection and hit queries: no React or React Flow imports. */
|
||||||
// 模型到场景投影、几何缓存和受预算控制的命中查询。
|
// 模型到场景投影、几何缓存和受预算控制的命中查询。
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],
|
|||||||
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
|
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
|
||||||
* This uses a structural DOM port so pure consumers need no browser globals. */
|
* This uses a structural DOM port so pure consumers need no browser globals. */
|
||||||
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
|
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
|
||||||
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */
|
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。
|
||||||
|
* closest 可查节点的祖先;两个标记独立累计,可同时为 true。DOM 端口异常原样传播,不在此兜底成未编辑。 */
|
||||||
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
|
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
|
||||||
let textEditing=false,overlay=false;
|
let textEditing=false,overlay=false;
|
||||||
for(const target of [event.target,...(event.composedPath?.()??[])]){
|
for(const target of [event.target,...(event.composedPath?.()??[])]){
|
||||||
@@ -28,7 +29,9 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:(
|
|||||||
}
|
}
|
||||||
return {textEditing,overlay};
|
return {textEditing,overlay};
|
||||||
}
|
}
|
||||||
/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */
|
/** 已 defaultPrevented 的事件先返回 unclaimed,即使同时是文本/浮层输入也不再次认领。
|
||||||
|
* composing/textEditing 返回 text,overlay 返回 overlay;这两层只是上下文判断,不调用下方 handler。
|
||||||
|
* gesture→tool→shortcut 同步短路,抛错直接传播;返回 claim 不会自行 preventDefault/stopPropagation。 */
|
||||||
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
|
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
|
||||||
if(input.defaultPrevented)return 'unclaimed';
|
if(input.defaultPrevented)return 'unclaimed';
|
||||||
if(input.composing||input.textEditing)return 'text';
|
if(input.composing||input.textEditing)return 'text';
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
|
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
|
||||||
*/
|
*/
|
||||||
/** One renderer store's transient measurement requests. Not model or history state. */
|
/** One renderer store's transient measurement requests. Not model or history state. */
|
||||||
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */
|
/** 每个 renderer store 独立创建:owners 记录挂载 token→nodeId,dirty 只记录待测 token。
|
||||||
|
* defer 默认为微任务;update 接收执行时仍有效的 ID,不接触文档。工厂没有全局 dispose/flush 公开入口。 */
|
||||||
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
|
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
|
||||||
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
|
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
|
||||||
/** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */
|
/** 执行时解析仍有效 owner 并按 ID 去重,清 queued/dirty 后 update;重入 invalidate 可安排下一轮。
|
||||||
|
* update 异常直接传播,本批不自动回队重试;卸载 token 的请求被排除,新 token 须自行 invalidate 才入本批。 */
|
||||||
function flush(){
|
function flush(){
|
||||||
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
|
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
|
||||||
if(ids.size)update([...ids]);
|
if(ids.size)update([...ids]);
|
||||||
@@ -16,7 +18,8 @@ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flus
|
|||||||
acquire(id:string){
|
acquire(id:string){
|
||||||
const owner=Symbol(id);owners.set(owner,id);
|
const owner=Symbol(id);owners.set(owner,id);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */
|
/** 活跃 token 去重变脏,queued 防重复 defer;多个 token 若同 nodeId,flush 时再合成一个 ID。
|
||||||
|
* 不捕获 defer 异常:若宿主抛错但未排任务,queued 不自动复位,宿主应使用可靠调度端口。 */
|
||||||
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
|
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
|
||||||
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
|
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
|
||||||
dispose(){owners.delete(owner);dirty.delete(owner);},
|
dispose(){owners.delete(owner);dirty.delete(owner);},
|
||||||
|
|||||||
@@ -9,19 +9,24 @@ import {fault} from '../canvas-core/values.ts';
|
|||||||
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
|
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
|
||||||
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
|
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
|
||||||
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
|
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
|
||||||
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */
|
/** entries 保存 overlayId→{token,entry},snapshot 沿用 Map 插入顺序(替换同 ID 不移位),index 只按屏幕 bounds 查询。
|
||||||
|
* 不订阅文档/视口,不自行核 target 存活;调用者更新位置,并在 visible 时提供当前目标有效性判断。 */
|
||||||
export function createOverlayIndex(viewId:string){
|
export function createOverlayIndex(viewId:string){
|
||||||
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
|
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
|
||||||
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */
|
/** 先更新 snapshot/index,再同步通知监听集合副本;只发布位置事实,不挂载 DOM。
|
||||||
|
* 监听器异常直接传播且会中断后续通知,不回滚已发布条目;不能据 register/update 抛错推断它未生效。 */
|
||||||
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
|
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
|
||||||
/** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */
|
/** 重建并冻结 entry 外壳/target/bounds,校验 ID/placement/目标代次和合法矩形。
|
||||||
|
* focused/composing 的布尔值由类型合同约束,此处不另验;扩展字段浅保留,不是任意对象深克隆。 */
|
||||||
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
|
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
|
||||||
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
|
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
|
||||||
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
|
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||||||
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
|
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
|
||||||
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
|
* 注册时 ++serial 分配 token,覆盖注册使旧租约失效;update 先检查 token,再捕获并按 JSON 文本比较。
|
||||||
|
* 等值 update 不发布,register 替换始终发布;release/clear 只移除瞬态浮层,不删除元素。 */
|
||||||
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
|
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
|
||||||
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */
|
/** 先验证查询矩形并查网格,再按 snapshot 顺序筛选:current(target) 必须真,之后才考虑焦点/组合状态或交集。
|
||||||
|
* 焦点/输入法保留不会绕过目标有效性,也不自动移除过期 entries;返回新数组,不发布位置更新。 */
|
||||||
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
|
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
|
||||||
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
|
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
|
||||||
clear(){entries.clear();publish();},
|
clear(){entries.clear();publish();},
|
||||||
|
|||||||
@@ -12,18 +12,23 @@ const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].eve
|
|||||||
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
|
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
|
||||||
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
|
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
|
||||||
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
|
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
|
||||||
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */
|
/** cellSize 决定网格尺度,maxCells 限单区域/查询展开格数,默认512和256。
|
||||||
|
* rows 存矩形,cells 存格→ID桶,members 存 ID→格键,overflow 存无法安全展开的 ID;全部可从输入重建。
|
||||||
|
* maxCells 不限制总区域数/总表大小;大区域或大查询保守回退扫描,可能线性耗时。 */
|
||||||
export function createPaintIndex(cellSize=512,maxCells=256){
|
export function createPaintIndex(cellSize=512,maxCells=256){
|
||||||
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
|
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
|
||||||
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
|
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
|
||||||
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */
|
/** 用 floor 算包含两端边界的格区间,负坐标也一致;恰好压格线的矩形可占相邻两格。
|
||||||
|
* 不安全整数或格数超 maxCells 返回 undefined,避免巨大循环,随后通过真实矩形交集排除假阳性。 */
|
||||||
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
|
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
|
||||||
/** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */
|
/** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */
|
||||||
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
|
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */
|
/** 先复制全部输入矩形并验证非空 ID/唯一性/合法矩形,再移除消失项并更新变化项。
|
||||||
|
* 不变行保留现有桶;大区域归 overflow 每次查。格式校验失败不开始改表,但不承诺任意运行时异常都回滚。 */
|
||||||
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
|
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
|
||||||
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */
|
/** 覆盖格+overflow 合并候选,无法安全展开的大查询改扫全部 rows;最后含边界相交精筛。
|
||||||
|
* 返回新 Set(只读仅是类型),外部改它不影响索引;不排序,不能用迭代次序当绘制层序。 */
|
||||||
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
|
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
|
||||||
/** 释放所有可重建空间表,不保留跨场景的区域引用。 */
|
/** 释放所有可重建空间表,不保留跨场景的区域引用。 */
|
||||||
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
|
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
|
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
|
||||||
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
|
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
|
||||||
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
|
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
|
||||||
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */
|
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。
|
||||||
|
* 缺 hasPointerCapture 的匹配目标也视为已丢失;此函数不释放 DOM 捕获、不提交/取消 editor 手势。 */
|
||||||
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
|
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
|
||||||
/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */
|
/** setPointerCapture 未抛错则返回 true,不再调用 hasPointerCapture 确认平台状态。
|
||||||
|
* 平台拒绝/节点卸载等异常统一 false,具体是否继续/取消手势仍由调用方决定。 */
|
||||||
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
|
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
|
||||||
/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */
|
/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */
|
||||||
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
|
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type {projectCanvas,ProjectionNode} from './projection.ts';
|
|||||||
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
|
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
|
||||||
/** Extra node paint extents beyond native unrotated React Flow bounds. */
|
/** Extra node paint extents beyond native unrotated React Flow bounds. */
|
||||||
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
|
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
|
||||||
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */
|
* 只输出超出原生矩形的节点完整 paint AABB,不是只输出外溢条带;每帧遍历所有显示 node。
|
||||||
|
* 单节点协议失败记 unknown 后继续,不包含边/selection/手势状态,也不决定 renderer 最终是否保留元素。
|
||||||
|
* 不检查 registry 与 projection 的来源身份,调用方须保持同解释上下文;fallback 按矩形外壳解释。 */
|
||||||
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
|
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
|
||||||
const regions:PaintRegion[]=[],unknown:string[]=[];
|
const regions:PaintRegion[]=[],unknown:string[]=[];
|
||||||
for(const node of projection.nodes)try{
|
for(const node of projection.nodes)try{
|
||||||
@@ -36,7 +38,8 @@ const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b=
|
|||||||
/** 变换值比较避免无关根发布反复计算 paint 边界。 */
|
/** 变换值比较避免无关根发布反复计算 paint 边界。 */
|
||||||
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
|
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
|
||||||
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
|
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
|
||||||
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
|
* 显式声明后只比较列出的 element.data 顶层字段(不是点分路径),空数组表示声明不依赖 data。
|
||||||
|
* 依赖列表须随同一 protocol 对象保持不变;遗漏字段/原地修改声明不会被缓存自动推导为新协议。 */
|
||||||
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
|
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
|
||||||
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
|
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
|
||||||
const dependencies=protocol.dataDependencies;
|
const dependencies=protocol.dataDependencies;
|
||||||
@@ -51,14 +54,18 @@ function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:
|
|||||||
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
|
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
|
||||||
}
|
}
|
||||||
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
|
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
|
||||||
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
|
/** 视图级增量 paint 网格:entries 缓存每个节点的签名及 regions/unknown 结果,index 只存额外范围。
|
||||||
|
* 不增量维护输入成员表,每次 sync 仍遍历全部 node 并 index.sync 全部 regions;只节省未变几何计算。
|
||||||
|
* 由 registry 对象/project/canvas 隔离,不额外核 editor/view owner;宿主应给每个视图独立实例。 */
|
||||||
export function createProjectedPaintIndex(){
|
export function createProjectedPaintIndex(){
|
||||||
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
|
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
|
||||||
type Projection=ReturnType<typeof projectCanvas>;
|
type Projection=ReturnType<typeof projectCanvas>;
|
||||||
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
|
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
|
||||||
const entries=new Map<string,Entry>(),index=createPaintIndex();
|
const entries=new Map<string,Entry>(),index=createPaintIndex();
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */
|
/** 切 registry/project/canvas 先清表,逐节点比较签名,变化才重新捕获依赖并调用完整单节点算法。
|
||||||
|
* unknown 结果同样可缓存至签名变化;数据捕获/比较异常可能直接抛出,整轮并非事务回滚。
|
||||||
|
* query 是同一可变 index 的方法,旧返回对象的 query 会查询后续 sync 的网格;unknown 则是本轮冻结列表。 */
|
||||||
sync(projection:Projection,registry:Registry){
|
sync(projection:Projection,registry:Registry){
|
||||||
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
|
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
|
||||||
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
|
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ type Projection=ReturnType<typeof projectCanvas>;
|
|||||||
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
|
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
|
||||||
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
|
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
|
||||||
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
|
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
|
||||||
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
|
/** 创建视图专属结构共享器;reuse 会就地改传入 nodes/edges 数组,调用方应提供自有可变派生数组。
|
||||||
|
* 不深克隆/冻结显示对象;缓存的对象须按不可变约定使用,不能传入业务记录数组或原地修改旧投影。 */
|
||||||
export function createProjectionCache(){
|
export function createProjectionCache(){
|
||||||
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
|
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
|
||||||
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
|
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
|
||||||
/** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */
|
/** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */
|
||||||
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
|
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
|
||||||
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
|
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
|
||||||
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
|
/** 每个显示 ID 须有文档记录;生命周期键只核 incarnation+owner,不另含 component/dataVersion,显示差异由值比较处理。
|
||||||
|
* 仅 data/style/position 三个嵌套字段单独等价共享,再比较整个显示对象;新表只含本帧项。
|
||||||
|
* 不查投影 ID 重复、不检查 node/edge 与记录的全部对应语义;这依赖上游合法投影。 */
|
||||||
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
|
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
|
||||||
const next=new Map<Id,Retained<T>>();
|
const next=new Map<Id,Retained<T>>();
|
||||||
for(let index=0;index<values.length;index++){
|
for(let index=0;index<values.length;index++){
|
||||||
@@ -35,7 +38,8 @@ export function createProjectionCache(){
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */
|
/** owner/project/canvas 改变先清旧表/数组,再分别 retain 节点和边,逐项引用相同才复用整个旧数组。
|
||||||
|
* 总返回新的投影外壳,nodes/edges 可沿用旧引用;中途抛错可能已改传入数组,不提供事务回滚。 */
|
||||||
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
|
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
|
||||||
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
|
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
|
||||||
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
|
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
|
||||||
@@ -44,7 +48,8 @@ export function createProjectionCache(){
|
|||||||
},
|
},
|
||||||
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
|
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
|
||||||
getNode(id:Id){return nodes.get(id)?.value;},
|
getNode(id:Id){return nodes.get(id)?.value;},
|
||||||
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
|
/** 核上下文、incarnation/owner、renderer type、width/height 与 data 等价,决定能否借旧测量。
|
||||||
|
* 不要求 position/style/整个 node 等价,也不执行 DOM 测量;调用者仍负责应用测量结果的 UI 规则。 */
|
||||||
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
|
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
|
||||||
const old=nodes.get(value.id),record=document.elements.get(value.id);
|
const old=nodes.get(value.id),record=document.elements.get(value.id);
|
||||||
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
|
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '..
|
|||||||
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||||
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
|
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
|
||||||
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
||||||
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */
|
/** 降级原因/类型/版本及从已知结构派生的把手;节点显示还可保留字符串标题和通用身份/几何。
|
||||||
|
* 不透传整份未知 data 给旧 renderer,不等于删除原 payload 或保证显示完全不读取任何通用字段。 */
|
||||||
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
|
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
|
||||||
/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */
|
/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */
|
||||||
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||||
@@ -28,7 +29,10 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
|
|||||||
|
|
||||||
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
|
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
|
||||||
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
|
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
|
||||||
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */
|
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。
|
||||||
|
* 已知 data 字段浅展开后由通用显示字段覆盖,record-v1 另借用原 element/transform,不深复制模型记录。
|
||||||
|
* draggable 只按 known 与 geometry.move 能力判定,缺 renderer 的 fallback 不必自动只读;策略仍由 editor 检查。
|
||||||
|
* 返回可变派生对象供后续补把手/结构共享,只有 __headlessTarget 单独冻结,不是完整输出深冻结。 */
|
||||||
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
|
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
|
||||||
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
||||||
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
|
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
|
||||||
@@ -47,7 +51,9 @@ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,ca
|
|||||||
|
|
||||||
/** Endpoints are already resolved to their displayed node IDs by the caller. */
|
/** Endpoints are already resolved to their displayed node IDs by the caller. */
|
||||||
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
|
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
|
||||||
* 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */
|
* compat-v1 只对 target.id 匹配的边采用预览路径,变到场景后通过 connectionPathValid 才输出。
|
||||||
|
* record-v1 接原记录/transform,不在此嵌入预览或转换结构路径;原生 renderer 须从自己的受控显示上下文读取预览。
|
||||||
|
* data.path 从兼容 payload 中剔除,避免残留字段覆盖结构 path;输出仍是派生边,不改真实端点。 */
|
||||||
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
|
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
|
||||||
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
||||||
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
|
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
|
||||||
@@ -59,7 +65,10 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
|
|||||||
|
|
||||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||||
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
|
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
|
||||||
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */
|
* 只输出 owner 在当前展开范围的连线,再把隐藏后代端点映射到可见折叠入口。
|
||||||
|
* 输入须是已验证的文档树/registry;这里查显示根与可选 queries 身份,不重做完整文档校验或检测所有递归环。
|
||||||
|
* 不做 viewport 裁剪或网络/资源读取;输出全体展开场景,再由 renderer/paint 索引决定屏幕保留。
|
||||||
|
* 派生容器虽可变,消费者仍应按只读约定使用;transforms 可复用共享 origin/几何缓存值,不应原地改它们。 */
|
||||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
|
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
|
||||||
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
|
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
|
||||||
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
|
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
|
||||||
@@ -72,7 +81,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
|||||||
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
|
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
|
||||||
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
|
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
|
||||||
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
|
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
|
||||||
/** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */
|
/** 按成员稳定顺序深度优先:先投影入口,再用同一份预览几何/单位构造内部 canvas 变换。
|
||||||
|
* 隐藏分支用栈遍历后代建立 hiddenBy,不生成内部节点/边变换;无 queries 时反复从 canvas 表找子项。 */
|
||||||
const visit = (id: Id, depth: number) => {
|
const visit = (id: Id, depth: number) => {
|
||||||
const transform = transforms.get(id)!;
|
const transform = transforms.get(id)!;
|
||||||
for (const element of getMembers(id)) if (element.structure === 'node') {
|
for (const element of getMembers(id)) if (element.structure === 'node') {
|
||||||
@@ -91,10 +101,12 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
visit(canvasId, 0);
|
visit(canvasId, 0);
|
||||||
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。
|
// 节点/hiddenBy 全部就绪后再投影边,避免端点代理依赖节点是否已遍历。
|
||||||
|
// queries 路径汇集展开 owner 的边再按文档层序排,无 queries 则扫描 element 表;模型归属始终不变。
|
||||||
const edges: ProjectionEdge[] = [];
|
const edges: ProjectionEdge[] = [];
|
||||||
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
|
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
|
||||||
/** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */
|
/** 端点节点可见则保留 ID,否则按其 owner 的 hiddenBy 找折叠代理。
|
||||||
|
* 缺显示端点或两端同代理时仅省略本帧边,不从模型删除,也不改为自由端点。 */
|
||||||
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
|
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
|
||||||
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
|
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
|
||||||
if (!source || !target || source === target) continue;
|
if (!source || !target || source === target) continue;
|
||||||
@@ -121,7 +133,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
|||||||
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
|
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
|
||||||
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
|
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
|
||||||
}
|
}
|
||||||
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。
|
// 成功遍历结束才 end 清未访问几何项;不是 finally,异常时由下次完整轮次或视图 clear 清理缓存。
|
||||||
|
// document 是原不可变引用,nodes/edges/transforms 是本次派生容器,不发布 editor 修改。
|
||||||
cache?.end();
|
cache?.end();
|
||||||
return { document,canvasId,nodes, edges, transforms };
|
return { document,canvasId,nodes, edges, transforms };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
|
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
|
||||||
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
|
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
|
||||||
|
* 重导出不自动挂 Provider、attach view 或创建 engine;ReactFlowProjection* 只是结构类型,不运行数据转换。
|
||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
/** Explicit framework boundary; consumers install the declared optional peers. */
|
/** Explicit framework boundary; consumers install the declared optional peers. */
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ export type ReactRenderers={readonly catalog:RendererCatalog;readonly definition
|
|||||||
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
|
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
|
||||||
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
|
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
|
||||||
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
|
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
|
||||||
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */
|
/** $$typeof 为 react.memo 则原样返回;其它受支持组件按原引用弱缓存 React.memo 包装。
|
||||||
|
* 不调用 render、不解析 lazy 内容,也不缓存组件状态;包装身份稳定不保证所有 props 变化都免渲染。 */
|
||||||
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
|
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
|
||||||
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
|
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
|
||||||
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
|
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
|
||||||
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
|
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
|
||||||
}
|
}
|
||||||
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
|
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
|
||||||
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */
|
* 同一 structure 内 rendererId 可别名共用,但普通/预览组件引用必须一致;node/edge 类型表彼此独立。
|
||||||
|
* 标记对象只做结构识别,不证明组件安全、纯净或必能成功渲染;安装来源仍须受信。
|
||||||
|
* 每次调用新建目录/表/外壳,只有组件 memo 包装共享;冻结注册表不冻结函数闭包或 React 内部状态。 */
|
||||||
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
|
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
|
||||||
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
|
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
|
||||||
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
|
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
|
||||||
@@ -31,11 +34,13 @@ export function createReactRenderers(definitions:readonly ReactRendererDefinitio
|
|||||||
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
|
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
|
||||||
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
|
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
|
||||||
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
|
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
|
||||||
// 如提供模型 registry,就要求精确 componentId/dataVersion/structure 匹配,不能靠显示组件掩盖 schema 不兼容。
|
// 可选 registry 只核 componentId/dataVersion/structure 精确存在,不执行某个元素的数据 schema/迁移。
|
||||||
|
// 未提供 registry 也可装配纯显示目录,真正投影元素时仍需模型解释与 fallback 判断。
|
||||||
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
|
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
|
||||||
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
|
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
|
||||||
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||||
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。
|
// 定义保留原 render/preview 引用,类型表存编译后的 memo 引用;不混淆这两层身份。
|
||||||
|
// 缺 preview 则不创建对应 previewTypes 项,工厂不自动用正式组件充当预览;宿主决定降级路径。
|
||||||
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
|
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
|
||||||
}
|
}
|
||||||
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
|
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
|
|||||||
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
|
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
|
||||||
const CanvasContext=createContext<CanvasBinding|null>(null);
|
const CanvasContext=createContext<CanvasBinding|null>(null);
|
||||||
/** Borrows the engine and an already attached view. Unmount never closes the project. */
|
/** Borrows the engine and an already attached view. Unmount never closes the project. */
|
||||||
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */
|
/** value 只按 engine/viewId 引用 memo 并冻结外壳,children 使用 React Context;不是项目实例拥有者。
|
||||||
|
* 不 attach/detach view、不创建/销毁 engine;若使用交互 view,应由外层宿主先装配,卸载 Provider 也不关闭项目。 */
|
||||||
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
|
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
|
||||||
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
|
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
|
||||||
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
|
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
|
||||||
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
|
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
|
||||||
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */
|
* selector 或等价函数换引用时重建派生 store,但可按 committed.current 复用等价结果,避免无意义选中值换身份。
|
||||||
|
* effect 才推进已提交基线,不在 render 中认领提交;source/selector/isEqual 须遵守同步不可变读取合同。
|
||||||
|
* getServerSnapshot 也使用同一 store.getSnapshot,不创建独立服务端模型,宿主须提供可稳定读取的绑定。 */
|
||||||
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
|
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
|
||||||
const committed=useRef<{value:T}|null>(null);
|
const committed=useRef<{value:T}|null>(null);
|
||||||
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
|
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
|
||||||
@@ -47,7 +50,9 @@ function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(
|
|||||||
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
|
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
|
||||||
export function useElement(ref:Id|Target):ElementView;
|
export function useElement(ref:Id|Target):ElementView;
|
||||||
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
|
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
|
||||||
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */
|
/** 字符串 ID 跟随当前生命周期;Target 仅在 present 且 incarnation 不同返回 stale,absent/unloaded 原样保留。
|
||||||
|
* 每个 engine 共用按 ID 源;hook 再派生代次/selector 结果,默认完整视图 equal,自定义选择默认 Object.is。
|
||||||
|
* stale 带当前元素供展示,但不自动把旧 Target 升级成新代次的写入权限。 */
|
||||||
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
|
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
|
||||||
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
|
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
|
||||||
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
|
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
|
||||||
@@ -55,7 +60,9 @@ export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEq
|
|||||||
}
|
}
|
||||||
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
|
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
|
||||||
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
|
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
|
||||||
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */
|
/** 按 engine/id/incarnation memo 动作外壳并捕获冻结 Target,之后改传入 target 对象不影响旧闭包。
|
||||||
|
* rename/setData/resize/remove/moveTo 都只是普通 command;resize 接 Partial<Geometry>,不只限 width/height。
|
||||||
|
* 调用时 editor 再核代次/策略/事务,不乐观修改 React 局部文档,也不在动作闭包里排网络请求。 */
|
||||||
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
|
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
|
||||||
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
|
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
|
||||||
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
|
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
|
||||||
@@ -69,16 +76,20 @@ const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSo
|
|||||||
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
|
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
|
||||||
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
|
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
|
||||||
/** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。
|
/** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。
|
||||||
* 它不是跨两源的独立事务,合法性由使用方的目标代次校验保证。 */
|
* 顺序读两源,不是跨源事务或原子快照锁;目标合法性仍由 hooks/命令校验。
|
||||||
|
* 正常退订依次 a()/b(),没有 finally 隔离,宿主取消端口须不抛错;右侧注册失败时尽量撤回左侧。 */
|
||||||
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
|
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
|
||||||
let previous:readonly[A,B]|undefined;
|
let previous:readonly[A,B]|undefined;
|
||||||
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
|
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
|
||||||
}
|
}
|
||||||
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */
|
/** 要求 Provider 有非空 viewId;这里只补绑定形状,不在此 attach 或验证该视图此刻仍注册。
|
||||||
|
* session 操作入口继续核视图/手势生命周期,不能凭这个返回对象绕过所有权检查。 */
|
||||||
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
|
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
|
||||||
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */
|
/** 将 Error 风格异常的 code/targets 转为 Availability,缺 code 用 binding_failed。
|
||||||
|
* 不归一化任意 thrown 值;null/undefined 不符合这里直接读属性的错误合同。 */
|
||||||
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
|
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
|
||||||
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */
|
/** 成对订阅元素查询和 view/element 模式;缺元素或旧代次读成 null,不把旧元素的编辑模式套给新元素。
|
||||||
|
* setMode 调用时再核当前 incarnation,然后由 sessions.mode 检查视图;返回可用性,不直接编辑内容/history。 */
|
||||||
export function useMode(target:Target){
|
export function useMode(target:Target){
|
||||||
const {engine,viewId}=useView(),{id,incarnation}=target;
|
const {engine,viewId}=useView(),{id,incarnation}=target;
|
||||||
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
|
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
|
||||||
@@ -87,7 +98,9 @@ export function useMode(target:Target){
|
|||||||
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
|
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
|
||||||
}
|
}
|
||||||
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
|
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
|
||||||
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */
|
* controls 内 lease 仅是本 hook 捕获的数字 token,不是另一份手势事实;真实 active/preview 在 sessions。
|
||||||
|
* effect 清理先核 token/view/active,仅取消自己取得的手势;旧组件卸载不能取消后来另一视图的新手势。
|
||||||
|
* begin/update/commit 沿用 session 的结果或异常;拥有手势后进入 commit 的 try/finally 才保证清 lease,未拥有则直接拒绝。 */
|
||||||
export function useGestureBindings(target:Target){
|
export function useGestureBindings(target:Target){
|
||||||
const {engine,viewId}=useView(),{id,incarnation}=target;
|
const {engine,viewId}=useView(),{id,incarnation}=target;
|
||||||
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
|
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
|
||||||
@@ -106,7 +119,8 @@ export function useGestureBindings(target:Target){
|
|||||||
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
|
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
|
||||||
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
|
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
|
||||||
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
|
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
|
||||||
/** 单操作诊断读取,返回逻辑操作及关联冻结请求,不提供直接改任务状态的方法。 */
|
/** 每 engine/id memo 派生源,useSyncExternalStore 读取逻辑操作和关联请求;缺 ID 返回 undefined。
|
||||||
|
* 这是诊断订阅,不轮询服务、不授予直接修改 phase/request 的接口。 */
|
||||||
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
|
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
|
||||||
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
|
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
|
||||||
type Durability=ReturnType<ProjectEngine['getDurability']>;
|
type Durability=ReturnType<ProjectEngine['getDurability']>;
|
||||||
@@ -117,7 +131,8 @@ const inactiveOwner=(value:Durability)=>value.closing||value.closed;
|
|||||||
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
|
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
|
||||||
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
|
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
|
||||||
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
|
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
|
||||||
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */
|
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。
|
||||||
|
* 计数与 durability 来自两条订阅,不建立新的事务根;status 是显示优先级归纳,synced 不证明远端生成已结束。 */
|
||||||
export function useSyncStatus(canvasId?:Id){
|
export function useSyncStatus(canvasId?:Id){
|
||||||
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
|
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
|
||||||
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
||||||
@@ -125,7 +140,10 @@ export function useSyncStatus(canvasId?:Id){
|
|||||||
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
|
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
|
||||||
}
|
}
|
||||||
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
|
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
|
||||||
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */
|
* 异步返回核 abort,渲染再核 state.engine/id/key;metadata 与句柄分别来自根和 effect,不另存原资源字节。
|
||||||
|
* key 只含上传 operationId/purge、binding.authority/digest,普通元信息变化不全都重开显示请求。
|
||||||
|
* attempt 只触发 effect 重试,不在返回 state 的身份键中;同来源 retry 的 loading 状态由 effect 启动时设置。
|
||||||
|
* ready 表示 URL 租约已取得,不等于图片 DOM 已解码/绘制或上传/同步完成。 */
|
||||||
export function useAsset(id:Id){
|
export function useAsset(id:Id){
|
||||||
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
|
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
|
||||||
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
||||||
@@ -134,7 +152,7 @@ export function useAsset(id:Id){
|
|||||||
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
|
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
|
||||||
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
|
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
|
||||||
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
|
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
|
||||||
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */
|
/** retry 仅增本 hook 的尝试序号,effect 清旧租约再取新显示句柄;不调用 engine.retry 或重新上传。 */
|
||||||
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
|
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
|
||||||
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
|
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCo
|
|||||||
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
|
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
|
||||||
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
|
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
|
||||||
/** Trusted installed renderers submit normal validated commands, never write a second model. */
|
/** Trusted installed renderers submit normal validated commands, never write a second model. */
|
||||||
/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */
|
/** 读取最近 Provider 的命令对象,缺失就同步抛错;hook 不缓存命令、不建立订阅或另建 editor。
|
||||||
|
* Provider 须注入 editor 的受检门面;Context 本身不验证任意自定义函数是否真的经过发布边界。 */
|
||||||
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
|
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user