45 Commits

Author SHA1 Message Date
codex-bot c426e94b58 docs(tests): explain retained views resource handles and batch proofs 2026-11-12 02:43:36 +08:00
codex-bot 330d8d6e34 docs(tests): explain paint bounds hit queries and scene caching 2026-11-12 02:40:06 +08:00
Zayden-Jung ea102b9949 Merge pull request 'docs(tests): 说明核心、几何、输入与订阅测试' (#7) from codex/core-test-comments into main
Reviewed-on: #7
2026-11-11 18:39:28 +00:00
Zayden-Jung efcec4d603 Merge pull request 'docs(tooling): 说明构建脚本、配置与 SDK 宿主示例' (#6) from codex/build-script-comments into main
Reviewed-on: #6
2026-11-11 18:39:22 +00:00
Zayden-Jung e75b9ed5e4 Merge pull request 'docs(canvas): 说明新旧主画布视图与交互适配' (#5) from codex/headless-view-comments into main
Reviewed-on: #5
2026-11-11 18:39:10 +00:00
codex-bot 74748d6e23 docs(tests): explain input routing and subscription lifecycle coverage 2026-11-12 02:36:36 +08:00
codex-bot 3da19e288a docs(tests): explain query geometry and creation history scenarios 2026-11-12 02:33:00 +08:00
codex-bot 47e318682e docs(tests): explain core invariants and history evidence scenarios 2026-11-12 02:29:37 +08:00
codex-bot 9946142d5e docs(tooling): explain SDK packaging and icon build configuration 2026-11-12 02:25:44 +08:00
codex-bot 1c832988a2 docs(canvas): explain legacy editor tools and subgraph interactions 2026-11-12 02:21:27 +08:00
codex-bot 95e8a57019 docs(canvas): explain headless view subscriptions and interaction adapters 2026-11-12 02:17:32 +08:00
Zayden-Jung 6b74629d98 Merge pull request 'docs(canvas): 说明旧画布树状态与事务协调' (#4) from codex/canvas-tree-comments into main
Reviewed-on: #4
2026-11-11 18:16:16 +00:00
codex-bot 33f5c076f6 docs(canvas): explain legacy tree state and transaction coordination 2026-11-12 02:13:31 +08:00
Zayden-Jung bcf810998d Merge pull request 'docs(canvas): 补充加载预览与旧任务 hooks 注释' (#3) from codex/canvas-panel-comments into main
Reviewed-on: #3
2026-11-11 18:10:50 +00:00
codex-bot f657052fe9 docs(canvas): explain loading previews and legacy task hooks 2026-11-12 02:07:50 +08:00
Zayden-Jung fb737eeb0b Merge pull request 'docs(canvas): 补充任务面板注释并恢复 PR 交付' (#2) from codex/canvas-panel-comments into main
Reviewed-on: #2
2026-11-11 18:07:38 +00:00
codex-bot 6576a45c0a docs(canvas): explain task panels and restore PR delivery workflow 2026-11-12 02:04:00 +08:00
codex-bot 838d3e5c46 docs(canvas): explain text tools and geometry gesture lifecycles 2026-11-12 01:59:22 +08:00
codex-bot 01c4a9d8c5 docs(canvas): explain node editing scopes and display interactions 2026-11-12 01:55:38 +08:00
codex-bot b51d78b47d docs(ui): explain shared controls and canvas display adapters 2026-11-12 01:51:59 +08:00
codex-bot 1306df2b35 docs(workspace): explain host page and sidebar lifecycles 2026-11-12 01:46:09 +08:00
codex-bot 86dac408c3 docs(app): explain route proxy authentication and page composition 2026-11-12 01:41:22 +08:00
codex-bot aacafa7a9b docs(app): explain common API session workspace and geometry helpers 2026-11-12 01:36:26 +08:00
codex-bot c488bc86b6 docs(canvas): explain legacy state history synchronization and subgraphs 2026-11-12 01:32:05 +08:00
codex-bot f28a2a96bf docs(canvas): explain editor entry migration gates and loading lifecycles 2026-11-12 01:26:22 +08:00
codex-bot 450fa3f719 docs(canvas): explain Foundry transport model streams and service receipts 2026-11-12 01:20:49 +08:00
codex-bot 11eaac1f9f docs(canvas): explain React bindings renderer registration and view leases 2026-11-12 01:14:47 +08:00
codex-bot 3f7f8e4b7c docs(canvas): explain scene projection and conservative paint caches 2026-11-12 01:09:39 +08:00
codex-bot 45309eaf87 docs(canvas): explain binding input subscriptions and hit queries 2026-11-12 01:05:17 +08:00
codex-bot c3b7cf93d6 docs(canvas): explain engine recovery public APIs and shutdown 2026-11-12 01:00:57 +08:00
codex-bot 28f1a74d40 docs(canvas): explain resumable import assembly and batch ownership 2026-11-12 00:57:17 +08:00
codex-bot c59850e7a3 docs(canvas): explain receipt coordination run events and image preparation 2026-11-12 00:53:32 +08:00
codex-bot f8e5a07233 docs(canvas): explain engine ownership durability and file retention 2026-11-12 00:49:56 +08:00
codex-bot 6b86ab1d53 docs(canvas): explain run polling and explicit conflict recovery 2026-11-12 00:44:58 +08:00
codex-bot 06a422045f docs(canvas): explain import execution and lazy copy source recovery 2026-11-12 00:37:50 +08:00
codex-bot 72e84a6cc1 docs(canvas): explain import parsers, identities and integrity gates 2026-11-12 00:31:08 +08:00
codex-bot 68e655b52c docs(canvas): explain streamed loading and certified scope admission 2026-11-12 00:25:26 +08:00
codex-bot b471d88520 docs(canvas): explain compute workers and display resource lifetimes 2026-11-12 00:19:19 +08:00
codex-bot af8e2fe76b docs(canvas): explain recovery encoding and history replay evidence 2026-11-12 00:13:52 +08:00
codex-bot da00490ded docs(canvas): explain host ownership, asset retention and durable storage 2026-11-12 00:07:21 +08:00
codex-bot 4c1b35e0cf docs(canvas): explain ledger scheduling and recovery invariants 2026-11-12 00:00:36 +08:00
codex-bot 391cd48be8 docs(canvas): explain query indexes, copy checkpoints and runtime ports 2026-11-11 23:54:16 +08:00
codex-bot 62a0755350 docs(canvas): explain gestures, coordinate spaces and creation planning 2026-11-11 23:46:22 +08:00
codex-bot afa30d4b35 docs(canvas): explain registry contracts and editor publication flow 2026-11-11 23:41:13 +08:00
codex-bot aaeb5acf6e docs(canvas): explain document changes and history lifecycle in Chinese 2026-11-11 23:35:52 +08:00
258 changed files with 5026 additions and 19 deletions
+1 -1
View File
@@ -14,6 +14,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
- 按逻辑完整、可验证的工作单元提交;同一目标可以包含多个 commit。目标或可交付阶段完成并通过适当检查后,主动推送,不必每次提交后立即推送。
- 连续收到需求时,结合当前目标判断是继续同一工作单元,还是拆成独立提交;不要把无关需求混进同一个 commit。尚未验证或未完成的工作如实报告,不把推送成功当作功能验证通过。
- 每次提交前审查差异和暂存区,推送前检查远程状态和待推送 commit;只纳入本次负责的改动。保留用户或其他任务的未提交、未跟踪文件,不擅自暂存、丢弃、stash 或提交。
- 分支和 PR 方式遵循用户当前明确授权。本项目当前阶段已获授权直接推送 main、暂不创建 PR;该阶段结束或用户调整约定后,恢复全局工作分支和 PR 流程。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。
- 分支和 PR 方式遵循用户当前明确授权。本项目已恢复工作分支和 PR 流程:完成并验证后推送工作分支、创建或更新面向 main 的 PR,等待用户手动合并;不直接推送 main,也不自行合并 PR。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。
- 涉及 gitea.entroia.com 时始终使用专用 codex-bot 身份和隔离 SSH 配置,遵守全局身份约定。
- 每次交付用中文简要说明涉及的文件与修改目的、检查结果、分支、commit 标识及 push 状态;有阻碍及时说明,已获授权的常规操作无需反复询问。
+31
View File
@@ -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.
* 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.
@@ -9,15 +14,24 @@ import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
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'};
// 内置组件之外注册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:[]}})]);
// 空项目只有根canvas;server/version模拟服务端基线,image模拟本地恢复镜像,两者职责分开。
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
let image=null,leader,fence=0,server=original,version=1;
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
const files=new Map(),requests=new Map();
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
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;},
/** 返回镜像克隆,调用方不能直接修改存储事实;空image代表首次打开。 */
async recover(handle){check(handle);return structuredClone(image);},
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
async commit(handle,batch){
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);}
@@ -25,11 +39,15 @@ const 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};
},
/** 文件读取也校验leader;close断言所有engine已释放领导权,不负责清理生产存储。 */
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:[]});
/** 最小协议2传输适配:模型GET、请求回执GET及操作POST;未实现任意Foundry端点或HTTP鉴权。 */
const transport=async input=>{
input.signal?.throwIfAborted();
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
if(input.method==='GET'){
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
const id=input.path.split('/').at(-1),known=requests.get(id);
@@ -37,32 +55,45 @@ const transport=async input=>{
}
const body=JSON.parse(input.body),known=requests.get(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};}
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
assert.deepEqual(body.expected_versions,{[scope.projectId]: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}};
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});
/** 新建engine注入scope/registry/storage/persistence与ID生成器,所有宿主依赖显式传入。 */
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
let engine=await open();
try{
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
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}};
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
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);
// 合法事务将移动和增量组合成一个历史单元;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.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);
// 临时手势的有效几何可变化,但cancel后文档仍x=32且请求数不增加,证明预览未被当持久编辑发送。
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);
// 深复制走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 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();
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 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));
// 输出可机器读取的结果摘要;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}));
}finally{await engine.close();storage.close();}
+9
View File
@@ -1,6 +1,12 @@
/**
* Next应用配置。为webpack与Turbopack安装相同ReactFlow编译扩展,并给带内容摘要的图标分片设置长期缓存。
* 此处只配置应用编译/响应头,不改变headless SDK导出或服务端数据协议。
*/
import type { NextConfig } from "next";
/** StrictMode显式开启开发检查,poweredByHeader关闭Next标识响应头;其余保持框架默认行为。 */
const config: NextConfig = {
reactStrictMode: true, poweredByHeader: false,
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
webpack(config) {
config.module.rules.push({
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
@@ -8,6 +14,7 @@ const config: NextConfig = {
});
return config;
},
// Turbopack使用条件路径匹配同一个入口;单独配置以免切换构建器时丢失保留节点扩展。
turbopack: {
rules: {
"*": {
@@ -16,6 +23,8 @@ const config: NextConfig = {
},
},
},
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
async headers() {
return [{
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
+4
View File
@@ -1 +1,5 @@
/**
* CSS构建入口:启用项目安装的Tailwind PostCSS插件,供Next样式处理链读取。
* 插件选项为空对象表示使用默认配置;这里不执行CSS生成,也不在导入时启动构建。
*/
export default { plugins: { "@tailwindcss/postcss": {} } };
+11
View File
@@ -1,11 +1,19 @@
/**
* 独立headless SDK的产物生成脚本。先清理.headless-dist,再用tsconfig.headless.json编译四个库目录,修正声明与Worker引用。
* 最后复制包清单、无UI宿主示例和验收文档;失败立即抛出,不把半成品报告成可用包。执行会删除并重建该产物目录。
*/
import { execFileSync } from 'node:child_process';
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
// 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
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'});
/** 递归只处理.d.ts的相对模块说明符,将源.ts后缀改为.js;普通schema/data字符串不做全局替换。
* 这是针对当前输出格式的正则修补,不是任意TypeScript语法的完整重写器。 */
async function declarations(directory){
for(const entry of await readdir(directory,{withFileTypes:true})){
const path=join(directory,entry.name);
@@ -19,12 +27,15 @@ async function declarations(directory){
}
}
}
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
await declarations(join(output,'modules'));
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
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`;
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
await readFile(join(output,'modules/canvas-runtime',`${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));
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
+5
View File
@@ -1,3 +1,7 @@
/**
* 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。
*/
/** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */
export const chinese = {
"Star": "星星",
"Heart": "爱心",
@@ -45,4 +49,5 @@ export const chinese = {
"Triangle": "三角形",
"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: "灯泡 灵感" };
+18
View File
@@ -1,3 +1,7 @@
/**
* 重建画布图标目录与路径分片的离线资源脚本。使用已安装Lucide与指定本地Tabler包,写目录JSON、摘要文件名分片和许可证。
* 版本检查固定输入格式;不会联网下载包,也不删除旧摘要分片,调用者需按脚本注释准备输入并从仓库根执行。
*/
// 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
import { readFile, writeFile, mkdir } from "node:fs/promises";
@@ -6,13 +10,16 @@ import { resolve } from "node:path";
import dynamicIconImports from "lucide-react/dynamicIconImports";
import { chinese, keywords } from "./canvas-icon-labels.mjs";
// 命令行参数是已解压Tabler包目录;同时校验两套依赖版本,防止升级后未经审查改变SVG/导出结构。
const tablerDir = process.argv[2];
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"));
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"));
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; };
/** 用slug正则派生分类,一个图标可匹配多个;未匹配的记录categories为空,UI可归“其他”。 */
const categories = [
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/],
@@ -24,17 +31,21 @@ const categories = [
["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/],
];
/** 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"]);
// Map以唯一name聚合元数据和SVG节点,Lucide加载器的__iconData提供路径,尚未在此写最终分片。
const entries = new Map();
for (const [slug, load] of Object.entries(dynamicIconImports)) {
const name = pascal(slug);
const { __iconData } = await load();
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 tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
for (const [index, slug] of tabler.entries()) {
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([
["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]),
])]);
@@ -42,19 +53,24 @@ for (const [index, slug] of tabler.entries()) {
const name = "Tabler" + pascal(slug);
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 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; };
// 目录去掉SVG node,仅保留检索元数据;路径放入分片,Lucide分96桶,Tabler集中一个桶。
const catalog = records.map(({ node, ...entry }) => {
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
if (!shards.has(key)) shards.set(key, {});
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(" ") };
});
// 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
await mkdir(assetDir, { recursive: true });
let totalBytes = 0;
const assets = {};
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
for (const [key, nodes] of shards) {
const content = JSON.stringify(nodes);
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
@@ -63,8 +79,10 @@ for (const [key, nodes] of shards) {
await writeFile(`${assetDir}/${filename}`, content + "\n");
totalBytes += Buffer.byteLength(content);
}
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
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(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/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`);
+10
View File
@@ -1,15 +1,25 @@
/**
* 应用编译期的ReactFlow可见性扩展。为视口外但仍有焦点/手势/浮层的显式保留对象扩展原生可见集合。
* 只转换匹配到的编译输入字符串,不修改共享node_modules文件,也不进入独立headless SDK的运行逻辑。
*/
// 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.
// 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
const version = require('@xyflow/react/package.json').version;
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
module.exports = function retainVisibility(source) {
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 edges = 'isEdgeVisible({\n sourceNode,';
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
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) {
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
}
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
return source
.replace(nodes, `Array.from(new Set([
...${nodes},
+39
View File
@@ -1,14 +1,25 @@
/**
* Foundry的同源服务端代理。浏览器只持HttpOnly会话Cookie,代理转为上游Bearer;
* 限定可转发路径/查询/header,处理体积、超时、素材类型和会话续期。领域回执/模型校验仍由客户端服务适配器执行。
* canonical文档和AI冻结写入保留原字节,模型/图片大响应流式转发,避免代理重编码破坏请求身份或额外缓存整份模型。
*/
import { fetchCanvasResponse } from "@/lib/canvas-response";
import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie";
import { NextRequest, NextResponse } from "next/server";
/** 普通账户/工作区/material接口白名单;canvas与asset细分端点由下方独立表达式允许。 */
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
/** 明确列出旧canvas、新model增量/迁移、子图事务和AI端点,拒绝把代理变成任意上游路径通道。 */
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
/** 素材上传/原件读取、操作回执和授权元数据是不同路径,后续按类别选择解析与响应方式。 */
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:(?:operations|metadata)\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/;
/** 普通请求体上限1MiB;画布5MiB、multipart素材21MiB由handler显式覆盖。 */
const bodyLimit = 1024 * 1024;
// Route Handlers do not have the Pages Router bodyParser size limit.
/** 逐块计数后再组装请求体,超限取消reader,finally释放锁。
* binary原样返回字节供冻结JSON/multipart转发,其它请求才解析JSON;这不是无限body缓冲。 */
async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
const reader = req.body?.getReader();
if (!reader) return {};
@@ -28,11 +39,14 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
} finally {
reader.releaseLock();
}
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
if (!size) return binary ? new Uint8Array() : {};
const bytes = Buffer.concat(chunks);
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
}
/** 共用GET/POST/PATCH/DELETE处理器。params是当前Next版本的异步路由参数,先await再匹配路径。
* 所有本代理响应默认no-store;HTTP成功/失败保持上游状态,上游调用/响应解析异常另转为502或超时504。 */
async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
@@ -40,8 +54,10 @@ async function handler(
const requestStarted = performance.now();
const requestStartedAt = new Date().toISOString();
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
/** 复用当前响应头集合,JSON错误和普通结果都带相同缓存/续期/诊断头策略。 */
const json = (body: unknown, status: number) =>
NextResponse.json(body, { status, headers: responseHeaders });
// 非GET且带Origin时比对host;缺Origin的请求在此不被拒绝,最终身份/权限仍由上游校验。
const requestOrigin = req.headers.get("origin");
if (req.method !== "GET" && requestOrigin) {
try {
@@ -53,6 +69,7 @@ async function handler(
}
const path = (await params).path.join("/");
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
// operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。
const assetReceipt = assetRoute.test(path) && /\/assets\/operations\//.test(path);
const assetMetadata = assetRoute.test(path) && /\/assets\/metadata\//.test(path);
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
@@ -60,6 +77,7 @@ async function handler(
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
let requestBody: unknown;
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
if (req.method !== "GET") {
try {
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI);
@@ -69,18 +87,24 @@ async function handler(
}
}
const bodyReceived = performance.now();
// 上游地址来自服务端环境,客户端path白名单不会让调用者自己指定任意目标源站。
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
// multipart沿用浏览器Content-Type和boundary,其余发JSON;不透传浏览器任意请求头。
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
// 仅精确协商的canonical GET模型流改变Accept,普通canvas请求继续兼容v2表示。
const modelStream = canonical && path.endsWith('/canvas/model') && req.method==='GET' && req.headers.get('accept')==='application/vnd.foundry.canvas-model+ndjson;version=1';
if(modelStream)headers.Accept='application/vnd.foundry.canvas-model+ndjson;version=1';
// Bearer从服务端读Cookie,禁止直接依赖客户端自行提供的Authorization冒充当前会话。
const session = req.cookies.get("foundry_session")?.value;
if (session) headers.Authorization = "Bearer " + session;
// 明确允许OAuth交换、条件版本、幂等键、账户绑定和资源ID;这些值仍需上游验证。
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account", "x-resource-id"]) {
const value = req.headers.get(h);
if (value !== null) headers[h] = value;
}
// 查询参数限定名单、单值且每值<300字符,重复值不选择性取第一项,避免前后端解释分歧。
const query = new URLSearchParams();
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);
@@ -88,18 +112,22 @@ async function handler(
}
const encodingStarted = performance.now();
// Frozen document and AI requests cross the proxy byte-for-byte.
// binary Buffer直接转发,不JSON.parse/stringify;普通业务JSON才重编码,GET不带body。
const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {});
const encodeMs = performance.now() - encodingStarted;
const started = performance.now();
try {
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
const streamedRead = snapshotRead || asset && req.method === "GET";
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
/** 大响应只记录传输时序/字节数/资源路径,不把图像/模型内容或会话令牌写入trace。 */
const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => {
console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace }));
}) : fetch;
if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId);
// 禁止fetch缓存和自动跟随上游跳转;流读取继承请求取消,具体头/body/idle计时由fetchCanvasResponse管理。
const upstream = await requestUpstream(
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
(query.size ? "?" + query.toString() : ""),
@@ -112,17 +140,20 @@ async function handler(
cache: "no-store",
},
);
// 转发明确允许的重试、版本和schema提示,不能用代理自己的时间推算服务端写入版本。
const retryAfter = upstream.headers.get("retry-after");
if (retryAfter) responseHeaders.set("Retry-After", retryAfter);
const etag = upstream.headers.get("etag");
if (etag) responseHeaders.set("ETag", etag);
const schema = upstream.headers.get("x-canvas-schema-version");
if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema);
// 既有会话仅在非401且上游给合法剩余秒数时续期;Secure还取决于生产环境及代理HTTPS头。
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
}
// 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。
if (asset && upstream.ok && req.method === "GET") {
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); }
@@ -135,12 +166,15 @@ async function handler(
const headersReceived = performance.now();
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
// canonical含错误回执也原流透传;旧snapshot只有成功GET走此路径。此处不读取/删除模型payload字段。
if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", modelStream && upstream.ok && upstream.headers.get('content-type')?.startsWith('application/vnd.foundry.canvas-model+ndjson;') ? upstream.headers.get('content-type')! : "application/json; charset=utf-8");
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
const body = upstream.status === 204 ? null : await upstream.json();
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(),
client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10,
@@ -155,6 +189,7 @@ async function handler(
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
if (body?.access_token) {
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
delete body.access_token;
@@ -162,6 +197,7 @@ async function handler(
const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER);
if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure));
}
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
if (path === "auth/logout" && upstream.ok)
responseHeaders.set(
"Set-Cookie",
@@ -170,6 +206,7 @@ async function handler(
if (upstream.status === 204)
return new NextResponse(null, { status: 204, headers: responseHeaders });
return json(body || { error: "upstream_unavailable" }, upstream.status);
// 这里只归一化代理传输/解析错误;成功开始转发后的流错误由流自身传播,不能补发第二个JSON响应。
} catch (error) {
responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
const timeout = error instanceof Error && error.name === "TimeoutError";
@@ -177,6 +214,7 @@ async function handler(
}
}
/** 显式拒绝PUT/HEAD/OPTIONS并说明允许方法,不依赖框架自动推导另一套接口行为。 */
function methodNotAllowed() {
return NextResponse.json({ error: "method_not_allowed" }, {
status: 405,
@@ -184,5 +222,6 @@ function methodNotAllowed() {
});
}
// 同一白名单/体积/会话策略覆盖允许的四种方法;具体操作是否合法仍由服务端端点决定。
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
+7
View File
@@ -1,5 +1,11 @@
/**
* 第三方登录的服务端跳转入口。只接受固定provider及单一PKCE challenge,
* 重定向到配置的Foundry项目,不交换令牌、不读取verifier,也不在此创建会话Cookie。
*/
import { NextRequest, NextResponse } from "next/server";
/** await异步provider参数,challenge须为43字符base64url;多值或未知provider直接400。
* 302显式no-store,避免浏览器把某一次登录challenge的跳转当成可复用响应。 */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> },
@@ -13,6 +19,7 @@ export async function GET(
!/^[-_a-zA-Z0-9]{43}$/.test(challenge)
)
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
// 上游源站/项目由宿主环境装配,不让查询参数决定任意OAuth重定向目标。
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
return NextResponse.redirect(
+4
View File
@@ -1 +1,5 @@
/**
* 认证回调页面复用登录页。真正读取URL片段、邮箱验证或PKCE交换的逻辑集中在login/page,
* 防止两条入口分别实现不同的令牌/Cookie处理规则。
*/
export { default } from "../../login/page";
@@ -1,6 +1,11 @@
/**
* 工作台画布路由的服务器入口,只解析异步路径参数并装配GraphPage。
* 具体board引用解析、账户作用域、新旧模型选择与编辑器生命周期由GraphPage及其加载器负责。
*/
import { Suspense } from "react";
import GraphPage from "@/components/workspace/graph-page";
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
const { workspace, board } = await params;
return (
+25
View File
@@ -1,3 +1,8 @@
/**
* 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板,
* 活动board元数据来自Context,画布业务状态由RuntimeHost拥有;这里不持有第二份元素文档。
* 浮动条在画布路由或侧栏折叠时显示,展开按钮仅在折叠时显示,收起按钮留在展开侧栏中。
*/
"use client";
import { useEffect, useRef, useState } from "react";
@@ -19,6 +24,7 @@ import { loadBoardDirectory } from "@/lib/board-directory";
import { BoardDirectory } from "@/components/workspace/board-directory";
import styles from "@/components/workspace/workspace.module.css";
/** 路径/查询参数优先定位空间,目录解析真实ID,UI状态与账户/目录异步生命周期分别维护。 */
export default function DashboardSidebar() {
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
@@ -42,13 +48,16 @@ export default function DashboardSidebar() {
const expandButton = useRef<HTMLButtonElement>(null);
const collapseButton = useRef<HTMLButtonElement>(null);
const hasToggled = useRef(false);
// 同步ref防重复点击创建,listVersion隔离迟到目录列表;二者都不是runtime账户authEpoch。
const createLock = useRef(false);
const listVersion = useRef(0);
const accountPopup = useRef<HTMLDivElement>(null);
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
useEffect(() => {
if (demo) {
setProfile({ id: "demo", username: "creator", display_name: "创作者", email: null });
@@ -77,6 +86,7 @@ export default function DashboardSidebar() {
return () => { active = false; controller.abort(); };
}, [demo, router]);
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
useEffect(() => {
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
@@ -93,13 +103,17 @@ export default function DashboardSidebar() {
return () => { active = false; controller.abort(); };
}, [workspace, demo, status, directoryRevision]);
/** 同工作区目录变化事件合并100ms后重读,清理时撤销计时和监听,不跨空间误刷新。 */
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
/** 忽略其它workspace事件;多次事件只触发一次revision递增和后续完整目录读取。 */
const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); };
window.addEventListener("endoleap:board-directory-changed", refresh);
return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); };
}, [workspace]);
/** 就绪且未重复创建才执行,真实模式必要时先建个人空间,demo只写本地目录。
* 成功递增listVersion防旧列表盖掉新项,再清搜索并导航;创建锁仅保护此UI操作,不替代账户身份护栏。 */
async function newPage() {
if (createLock.current || status !== "ready") return;
createLock.current = true;
@@ -121,6 +135,7 @@ export default function DashboardSidebar() {
finally { createLock.current = false; setCreating(false); }
}
/** 用户实际切换折叠后,下一帧聚焦新出现的展开/收起按钮;初始渲染不抢焦点。 */
useEffect(() => {
if (!hasToggled.current) return;
const frame = requestAnimationFrame(() => {
@@ -129,17 +144,20 @@ export default function DashboardSidebar() {
return () => cancelAnimationFrame(frame);
}, [collapsed]);
/** 先关闭账户popover再切布局,避免旧锚点残留弹层;焦点转移交给effect。 */
function toggleSidebar(value: boolean) {
accountPopup.current?.hidePopover();
hasToggled.current = true;
setCollapsed(value);
}
/** 关闭popover后打开设置/新空间对话框,避免两层交互同时争焦点。 */
function openPanel(mode: "settings" | "workspace") {
accountPopup.current?.hidePopover();
setPanel(mode);
}
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
async function logout() {
try {
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
@@ -147,13 +165,17 @@ export default function DashboardSidebar() {
} catch (e) { setError(errorText(e)); }
}
// 已解析活动board标题优先,退回目录标题或加载文案;不从另一工作区的活动记录借名。
const currentTitle = activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace
? activeBoard.title
: boards.find((board) => matchesRef(board, params.board))?.title || (params.board ? "正在读取图谱…" : "工作空间");
const name = profile?.display_name || profile?.username || "账户";
// 计数用标题直接匹配;树中保留的搜索祖先由BoardDirectory独立派生,因此数目不等于所有可见行数。
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
// UI能力只作提示/禁用,真实创建权限仍由服务端判定,未知空间不能当成已有编辑权限。
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
async function renameTitle(title: string) {
const id = activeBoard?.id || params.board;
if (!id || !workspace) return;
@@ -167,11 +189,14 @@ export default function DashboardSidebar() {
} catch (error) { throw new Error(errorText(error)); }
}
// 创建入口复用同一busy/权限状态,降低重复点击,但不在render中发请求。
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
// 尊重减少动态效果偏好;折叠侧栏同时inert/aria-hidden,避免不可见内容仍接受键盘交互。
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" ? "查看者" : "工作空间";
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
return (
<>
<AnimatePresence initial={false}>
+5
View File
@@ -1,9 +1,14 @@
/**
* 工作台共享布局,装配当前画布Context、侧栏及子路由,并加载React Flow默认样式。
* 布局本身只放侧栏与children;根叶页面为空,画布子路由通过children提供主编辑区。
*/
import "@xyflow/react/dist/style.css";
import { Suspense, type ReactNode } from "react";
import { ActiveBoardProvider } from "@/components/workspace/active-board";
import DashboardSidebar from "./dashboard";
import styles from "@/components/workspace/workspace.module.css";
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<ActiveBoardProvider>
+5
View File
@@ -1,3 +1,8 @@
/**
* 工作台根路径的空叶页面。共享dashboard布局继续显示侧栏,
* 这里返回null是路由占位,不代表工作区没有内容或需要创建一张空画布。
*/
/** 保留共享布局,不额外重复渲染工作台内容。 */
export default function DashboardPage() {
return null;
}
+6
View File
@@ -1,9 +1,14 @@
/**
* 整个App Router的根布局:统一页面元数据、全局CSS、应用反馈和CanvasRuntimeHost装配。
* 子页面/画布视图借用同一个宿主,普通路由切换不应各自创建另一份项目运行时。
*/
import { CanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import { AppFeedback } from "@/components/ui/app-feedback";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "@/styles/globals.css";
/** 标题/描述及不同色彩偏好的favicon,交给Next元数据系统生成页面头,不在客户端effect中改DOM。 */
export const metadata: Metadata = {
title: "啡跃 Endoleap · 让灵感有形",
description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
@@ -17,6 +22,7 @@ export const metadata: Metadata = {
},
};
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN" data-scroll-behavior="smooth">
+14
View File
@@ -1,3 +1,7 @@
/**
* 登录、注册及认证回调共用的客户端页面。左侧品牌说明为展示内容,右侧管理表单/OAuth/验证状态。
* 成功会话由同源代理写HttpOnly Cookie,组件不持有access_token;busy仅控制当前表单交互和加载提示。
*/
"use client";
import { useEffect, useState, type FormEvent } from "react";
@@ -13,6 +17,7 @@ import {
import { Brand } from "@/components/brand";
import { Button } from "@/components/ui/button";
import { api, errorText, startOAuth } from "@/lib/api";
/** 状态分别表示登录/注册模式、邮箱密码、密码可见性、请求忙碌和成功/错误文案,均不存入业务文档。 */
export default function Login() {
const router = useRouter();
const [register, setRegister] = useState(false),
@@ -22,6 +27,8 @@ export default function Login() {
[busy, setBusy] = useState(false),
[message, setMessage] = useState(""),
[error, setError] = useState("");
/** 页面挂载读取回调片段,先清掉URL查询/片段再发验证或交换请求;这样重跑不会再次消费同一片段。
* 此effect没有单独AbortController,不具备画布loader的导航代次隔离;结果由此页面的router/state回调处理。 */
useEffect(() => {
const hash = new URLSearchParams(location.hash.slice(1));
const code = hash.get("code"),
@@ -34,8 +41,10 @@ export default function Login() {
return;
}
setBusy(true);
// verifier从本标签页sessionStorage取出即删除,避免后续登录误复用;verify_email分支优先于code交换。
const verifier = sessionStorage.getItem("foundry_verifier");
sessionStorage.removeItem("foundry_verifier");
// 邮箱验证提交token;OAuth交换只用一次性code+verifier,请求代理负责把返回令牌改写成Cookie。
(verify
? api("auth/verify-email", {
method: "POST",
@@ -55,6 +64,8 @@ export default function Login() {
.catch((e) => setError(errorText(e)))
.finally(() => setBusy(false));
}, [router]);
/** 阻止原生表单跳转,提交当前模式对应端点;注册后提示验证邮件,登录成功才push工作台。
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
@@ -74,6 +85,7 @@ export default function Login() {
setBusy(false);
}
}
/** 清旧提示,创建PKCE后整页跳转;发起失败恢复表单状态,成功保持busy等待浏览器导航。 */
async function oauth(p: "google" | "slack") {
setError("");
setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…");
@@ -86,6 +98,8 @@ export default function Login() {
setError("无法启动登录,请重新尝试。");
}
}
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
return (
<div className="auth-page">
<aside className="auth-story">
+7
View File
@@ -1,7 +1,12 @@
/**
* 公开首页与已登录工作台的服务器分流。Cookie存在不是有效会话证明,需向profile端点验证后才重定向。
* 会话无效或服务暂不可达仍显示公开LandingPage,不因为一枚旧Cookie隐藏公开内容。
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import LandingPage from "@/components/landing-page";
/** 异步读取请求Cookie,带15秒时限/禁缓存验证上游;只需成功状态,不消费完整profile JSON。 */
export default async function Home() {
const session = (await cookies()).get("foundry_session")?.value;
let authenticated = false;
@@ -15,12 +20,14 @@ export default async function Home() {
redirect: "manual",
signal: AbortSignal.timeout(15000),
});
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
authenticated = response.ok;
await response.body?.cancel();
} catch {
// An unverified session must not hide the public landing page.
}
}
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
if (authenticated) redirect("/dashboard");
return <LandingPage />;
}
+5
View File
@@ -1,4 +1,9 @@
/**
* 公用品牌首页链接。组合Logo、中文名与英文名,供登录页、落地页等位置复用;不读取用户或工作区状态。
*/
import Link from "next/link";
/** light选择brand-light展示样式以及配套Logo资源:true用logo-dark.svg,false用logo-light.svg。
* 链接的aria-label已经说明品牌与目的地,图片使用空alt避免重复朗读;固定尺寸减少加载时布局跳动。 */
export function Brand({ light = false }: { light?: boolean }) {
return (
<Link
+14
View File
@@ -1,3 +1,7 @@
/**
* 旧版画布的节点AI浮层。通过useCanvasAI返回的控制接口管理生成,模型目录由旧画布AI接口读取。
* 配置草稿保存在本地state,save回调将配置写回旧节点;本组件不承担新版ledger调度与恢复。
*/
"use client";
import { useEffect, useState } from "react";
import { NodeToolbar, Position } from "@xyflow/react";
@@ -8,6 +12,7 @@ import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2";
import type { CanvasNode } from "@/lib/canvas";
import type { CanvasModel, useCanvasAI } from "./use-canvas-ai";
import styles from "./canvas.module.css";
/** 以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 }) {
const [models, setModels] = useState<CanvasModel[]>([]);
const [model, setModel] = useState(node.data.ai?.modelId || "");
@@ -19,7 +24,10 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
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 run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
// starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。
const busy = starting || step?.status === "queued" || step?.status === "running";
// 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。
// 清理时abort,失败和finally检查signal再更新;成功分支直接采用api返回的结果。
useEffect(() => {
if (!ai.live) { setLoading(false); return; }
const controller = new AbortController();
@@ -30,10 +38,16 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
}).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [workspace, boardId, image, ai.live]);
/** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
/** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */
const persist = () => { if (model && ai.live) save(config()); };
// output取当前步骤资源,文本内容则从最后一个step.output事件取;两者按同一board/node限定。
const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output;
const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text;
// 模型和传播开关变化即时save,正文blur保存;执行由ai.run负责,starting在finally复位。
// 上传中、演示或没有可用模型禁止启动;与新版不同,此处未要求prompt非空,也未在busy时禁用textarea。
// 冲突时保留结果并给显式apply入口,错误优先显示本面板错误;关闭/取消任务的行为交由父层与ai接口决定。
return <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
+9
View File
@@ -1,3 +1,6 @@
/**
* 从节点连接点发起的分支类型选择菜单。Portal挂在body,使用屏幕坐标定位;选择结果交给choose创建内容与连线。
*/
"use client";
import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@@ -6,10 +9,14 @@ import { FileText, ImagePlus, StickyNote, X } from "lucide-react";
import type { XYPosition } from "@xyflow/react";
import type { NodeDirection } from "@/lib/canvas";
import styles from "./canvas.module.css";
/** id/direction标识发起节点和边,point是菜单屏幕位置,at可由调用方保存拟创建位置;菜单不解释at。 */
export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition };
/** request为空时不显示,类型固定为文本/图片/脚本;这里只派发选择,不执行上传或文档修改。 */
export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
// 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。
// 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。
useEffect(() => {
if (!request) return;
const focus = document.activeElement as HTMLElement | null;
@@ -19,6 +26,8 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
}, [request, close]);
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
if (typeof document === "undefined") return null;
return createPortal(<AnimatePresence>{request && <motion.div ref={ref} key={request.id + request.direction} initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.96 }} transition={{ duration: reduce ? 0 : 0.14 }} role="dialog" aria-label="选择连接节点类型" className={styles.branchMenu} style={{ left: Math.max(12, Math.min(request.point.x + 12, window.innerWidth - 222)), top: Math.max(12, Math.min(request.point.y + 12, window.innerHeight - 208)) }}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
+36
View File
@@ -1,3 +1,7 @@
/**
* 画布连线显示与贝塞尔链编辑。根据端点生成路径,叠加箭头、选中轮廓和动画,选中时提供曲线点及控制柄。
* 指针拖动通过租约预览后提交;旧接口保留transient patch兼容路径。组件不自行同步服务端或管理撤销栈。
*/
"use client";
import { useContext, useEffect, useRef, useState, type PointerEvent } from "react";
@@ -15,11 +19,15 @@ import { EdgeToolbarControls } from "./edge-toolbar";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import styles from "./canvas.module.css";
/** 旧节点用绝对位置替换局部position后,交compatConnector计算旋转/缩放等兼容端点。 */
function connector(node:InternalNode<CanvasNode>,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);}
/** 新投影已经给出端点坐标,只需按连接边补朝外法向;避免再应用一遍旧节点几何变换。 */
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
return {point:{x,y},normal:normals[side]};
}
/** 生成可复用SVG端部标记;none不绘制,开放箭头不填充,其余按filled使用线色或画布底色。
* userSpaceOnUse使大小不随线宽缩放,auto-start-reverse支持起终两端朝向。 */
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
const type = value?.type || "none";
if (type === "none") return null;
@@ -27,6 +35,7 @@ function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: s
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
</marker>;
}
/** 读取两端内部节点、视口与编辑上下文;selectedKnot仅是当前控件选择,不进入连线数据。 */
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
const editing = useContext(EdgeEditing);
@@ -34,37 +43,49 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
const { zoom } = useViewport();
const reduce = useReducedMotion();
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
// 手势记录控制点id/part与捕获目标;事件监听通过AbortController统一拆除,lease可缺省以兼容旧编辑器。
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
// 原生监听通过ref使用最新move/stop/cancel,避免投影更新后继续用过期path或端点闭包。
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
// 只挂载一次的失焦/卸载监听经ref调用最新cancel,释放未完成手势。
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
// 任一端点节点未就绪就不绘制;新版端点使用ReactFlow计算结果,旧版由兼容几何计算。
if (!source || !target) return null;
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left");
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
// 贝塞尔链生成可绘制path;工具栏锚点取中间一段的t=0.5,属于近似中心而非整条曲线的弧长中点。
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
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 knots = data.path?.knots || [];
/** 最多64个中间曲线点;insertCurveKnot按给定点或默认位置拆分曲线,提交后选择新点。 */
function add(point?: { x: number; y: number }) {
if (knots.length >= 64) return;
const next = insertCurveKnot(from, to, data.path, point);
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
}
/** 仅未处理的主指针左键可开始。已有手势不被重复接管,另一指针到来先取消原手势。
* 捕获优先放在稳定的画布surface上,避免投影替换SVG控制点后丢掉后续事件。 */
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return;
// The SVG control can be replaced by a projection update. The view surface survives it.
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
const lease = editing.begin(props.id); if (lease === false) return;
const controller = new AbortController();
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
}
/** 屏幕点转画布点,并克隆当前path后更新选中部分;只接受当前捕获指针的事件。 */
function move(event: PointerEvent<SVGCircleElement>) {
const current = gesture.current;
if(!current)return;event.stopPropagation();
@@ -72,15 +93,18 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
if(!current)return;
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
else {
const knot = next.knots.find((knot) => knot.id === current.id);
if (!knot) return;
if (current.part === "position") knot.position = point;
else {
// corner两柄独立;smooth保持另一柄长度且方向共线反向;symmetric两柄长度也相同。
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
knot[current.part] = vector;
// Shift把该曲线点模式改为symmetric,这个mode会随本次path修改一起提交。
if (event.shiftKey) knot.mode = "symmetric";
if (knot.mode !== "corner") {
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
@@ -88,14 +112,17 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
}
}
}
// 优先本次租约的preview;旧版以transient=true派发path修改,结束时另行finish/cancel。
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
}
/** 抬起前再处理一次最后位置,随后清引用、拆监听、提交并释放捕获,避免释放事件重复结束。 */
function stop(event: PointerEvent<SVGCircleElement>) {
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 (current.lease) current.lease.commit(); else editing.finish?.();
releasePointerCapture(current.capture,current.pointerId);
}
/** 事件取消需匹配捕获;无参数用于失焦/卸载。先清引用和监听,再取消预览并释放捕获。 */
function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
if(event&&gesture.current)event.stopPropagation?.();
if(event&&!ownsPointerCapture(gesture.current,event))return;
@@ -105,7 +132,10 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
if (current.lease) current.lease.cancel(); else editing.cancel?.();
releasePointerCapture(current.capture,current.pointerId);
}
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
pointerHandlers.current = { move, stop, cancel };
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
* 方向键一步1单位、Shift为10单位;中间in/out柄的键盘分支直接返回,不修改曲线。 */
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
onKeyDown={(event) => {
@@ -117,6 +147,9 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
editing.patch(props.id, { ...data, path: next });
}
}} />;
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
@@ -124,6 +157,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
{props.selected && <>
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
// 工具栏模式应用于选中knot,找不到时退到最后一点;平滑/镜像优先以out柄为方向基准重算in柄。
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
knot.mode = mode; if (mode !== "corner") {
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
@@ -141,6 +175,8 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
</>}
</g>;
}
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
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 } };
+14
View File
@@ -1,11 +1,21 @@
/**
* 按名称异步加载画布SVG图标。先读取共享缓存,再订阅图标加载器,展示加载中/失败/已就绪三种图形。
* 组件负责显示和重试入口;网络并发、缓存以及订阅取消的实现位于canvas-icon-loader。
*/
"use client";
import { createElement, useEffect, useState } from "react";
import type { LucideProps } from "lucide-react";
import { loadIcon, peekIcon, type IconPaths } from "@/lib/canvas-icon-loader";
/** 兼容Lucide尺寸与描边属性,name默认Star;width/height未提供时跟随size。
* 成功后按加载器返回的SVG标签与属性创建子元素,避免把整段SVG作为HTML插入。 */
export function CanvasIcon({ name = "Star", size = 24, width = size, height = size, strokeWidth = 2, absoluteStrokeWidth, ...props }: LucideProps & { name?: string }) {
// 结果携带图标名,渲染时只采用当前name对应的内容,换名时不会短暂展示上一个图标。
const [result, setResult] = useState<{ name: string; paths?: IconPaths; error?: boolean }>(() => ({ name, paths: peekIcon(name) }));
// retry仅用于触发同名图标重新订阅;它不是全局缓存版本或网络请求ID。
const [retry, setRetry] = useState(0);
// 换名或点击重试后,先采用可同步取得的缓存,再等待loadIcon。清理时取消本组件订阅;
// AbortError不显示失败,其它错误显示重试占位。底层决定共享请求是否仍有其它使用者。
useEffect(() => {
const controller = new AbortController();
setResult({ name, paths: peekIcon(name) });
@@ -14,8 +24,12 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
});
return () => controller.abort();
}, [name, retry]);
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
const paths = result.name === name ? result.paths : undefined;
const failed = result.name === name && result.error;
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
// 加载中用虚线方框,失败用叉号与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"
{...props} data-icon-id={name} data-icon-state={paths ? "loaded" : failed ? "error" : "loading"}
onClick={failed ? (event) => { event.stopPropagation(); setRetry((value) => value + 1); } : props.onClick}>
+11
View File
@@ -1,9 +1,20 @@
/**
* 画布图片显示层:远端图片异步加载时,允许用Provider提供的本地预览覆盖,减轻上传或资源切换时的空白。
* 组件不上传文件、不管理Blob URL寿命;这些职责留给提供sources与loaded回调的上层。
*/
"use client";
import { createContext, useContext } from "react";
/** 按元素id查找预览URL,并在底层图片加载成功时通知上层;默认空Map与空回调支持独立渲染。 */
export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded: (id: string) => void }>({ sources: new Map(), loaded: () => {} });
/** src用于底层正式图片,title作为其替代文本;图片禁止原生拖拽,避免干扰画布手势。
* 底图使用懒加载、异步解码并禁止发送Referer;onLoad只通知上层,不直接删除预览。 */
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
if (!local || local === src) return remote;
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
// 覆盖图是装饰性的、忽略指针事件,读屏与节点交互都交给底层正式图片和外层节点。
return <div style={{position:"relative",width:"100%",height:"100%",minHeight:0,flex:1,borderRadius:"inherit"}}>{remote}<img className={className} src={local} alt="" aria-hidden draggable={false} style={{position:"absolute",inset:0,pointerEvents:"none"}} /></div>;
}
+15
View File
@@ -1,20 +1,35 @@
/**
* 将Portal中的节点浮层注册到视图OverlayIndex,提供屏幕边界、焦点及输入法组合态。
* 索引用于输入路由与浮层归属判断;DOM标记与事件监听在ref更换或卸载时清理,不写入文档。
*/
"use client";
import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react';
import {useViewport} from '@xyflow/react';
import type {OverlayIndex} from '@/lib/canvas-bindings/overlays';
import type {ProjectEngine} from '@/lib/canvas-runtime/workspace';
/** 新版Provider提供视图索引与engine;null允许旧编辑器或无engine的展示组件直接复用Hook。 */
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
/** 返回DOM ref回调。注册目标包含id/incarnation,避免同ID元素重建后仍沿用旧浮层身份。
* useId区分组件实例,placement区分用途;Portal位置不影响目标归属。 */
export function useCanvasOverlay(targetId:string,placement:string){
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
useViewport();
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
// 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
// 随机owner标记区分同一DOM上的新旧绑定,旧清理不能删除新绑定写入的数据属性。
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
// getBoundingClientRect给出屏幕坐标,四周扩大8px作为输入命中边界;焦点读取当前activeElement。
let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};};
// 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
// focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。
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标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。
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]);
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
}
@@ -1,3 +1,7 @@
/**
* 内置画布类型到React组件的装配表。把bindings层的renderer描述绑定到实际节点/连线组件,生成默认React渲染注册表。
* 这张表保存可复用的显示定义,不保存每个元素的数据、选择或编辑状态。
*/
"use client";
import type { NodeProps } from '@xyflow/react';
import type { CanvasNode } from '@/lib/canvas-elements';
@@ -9,6 +13,12 @@ import { PlainImageNode } from './plain-image-node';
import { FrameNode } from './frame-node';
import { IconNode } from './icon-node';
import { CanvasEdge } from './canvas-edge';
/** 新版分区复用FrameNode,但开启directAnchors,使它使用该模式的直接连接点。
* 其它NodeProps透传,包装层不再创建一份分区模型。 */
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
/** rendererId到节点组件的固定映射;这里的键与builtinRendererDefinitions中节点的rendererId配套。 */
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
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;}));
@@ -1,3 +1,7 @@
/**
* 卡片与纯文本共用的正文编辑交互。展示态用覆盖层接收选择/双击,编辑态让textarea接收原生文本操作。
* 正文直接来自data.text,每次输入交给actions.edit;本组件只保存是否正在编辑,不另存正文副本。
*/
"use client";
import { useEffect, useRef, useState } from "react";
@@ -7,21 +11,31 @@ import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
/** Single clicks select; native double-click timing controls entry into editing. */
/** plain选择纯文本样式、标签和占位文案;非plain按kind区分脚本/笔记占位。
* 对外动作经过渲染目标身份检查,具体文本事务与history分组由Provider实现。 */
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
const actions = useScopedNodeEditing(id,data.__headlessTarget);
const [editing, setEditing] = useState(false);
const textarea = useRef<HTMLTextAreaElement>(null);
// 按下位置和移动标记只用于区分拖动与双击;移动超过4px后不因双击事件进入编辑。
const press = useRef({ x: 0, y: 0, moved: false });
// 失去选择退出本地编辑态;此effect不直接调用endEditing,正常输入框blur另走结束回调。
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
// 进入编辑后聚焦到文本末尾,并禁止浏览器自动滚动,以免画布视口跳动。
useEffect(() => {
if (!editing) return;
const element = textarea.current;
element?.focus({ preventScroll: true });
element?.setSelectionRange(element.value.length, element.value.length);
}, [editing]);
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
function begin() { actions.begin(id); setEditing(true); }
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
const active = editing && selected;
const label = plain ? "纯文本" : data.title;
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
<textarea ref={textarea} style={textAppearance(data)} className={`${plain ? styles.plainText : styles.cardText} nodrag nowheel`}
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
@@ -1,6 +1,11 @@
/**
* 通用内容节点的四向连接点。只提供稳定方向ID和几何位置,实际连线命令由画布层接收。
*/
"use client";
import { Handle, Position } from "@xyflow/react";
import styles from "./canvas.module.css";
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
export function ContentHandles({ selected }: { selected: boolean }) {
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} 连接元素`} />)}</>;
}
+7
View File
@@ -1,10 +1,17 @@
/**
* 旧版画布未同步草稿的选择对话框。通过原生dialog提供模态展示,把恢复、导出或使用服务端版本交给调用方。
* 本组件不读取草稿、不写服务端,也不判断本地内容是否能安全覆盖;canRestore由上层计算。
*/
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import styles from "./canvas.module.css";
/** 可恢复时显示恢复按钮,否则提示子图关系变化;导出按钮仅在提供exportDraft回调时出现。 */
export function DraftRecovery({ restore, discard, canRestore = true, exportDraft }: { restore: () => void; discard: () => void; canRestore?: boolean; exportDraft?: () => void }) {
const dialog = useRef<HTMLDialogElement>(null);
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
// 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。
if (typeof document === "undefined") return null;
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}</p>
+7
View File
@@ -1,6 +1,13 @@
/**
* 连线编辑的React动作上下文。让连线组件调用begin/patch/finish/cancel,并读取当前活动集合。
* 真正的命令、事务和history由Provider注入,默认空实现仅支持未接入编辑器的渲染环境。
*/
"use client";
import { createContext } from "react";
import type { EditingLease } from "./editing-lease";
import type { EdgeData } from "@/lib/canvas-v2";
/** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。
* 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>() };
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
+12
View File
@@ -1,14 +1,26 @@
/**
* 连线的受控样式与曲线工具栏。接收当前data及回调,编辑外观、动画、传播开关和曲线点,不直接持有文档状态。
*/
"use client";
import { EdgeToolbar } from "@xyflow/react";
import { Plus, Trash2 } from "lucide-react";
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** x/y是工具栏锚点;count与knotMode来自外层曲线编辑器。add/remove/changeKnotMode委托外层选择具体曲线点。
* EdgeToolbar只负责屏幕定位,外层仅在选中连线时挂载本组件。 */
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 || {};
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
/** 箭头与箭尾共用的六种形状;实际SVG绘制由CanvasEdge中的Marker实现。 */
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
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()}>
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
+8
View File
@@ -1,9 +1,17 @@
/**
* 编辑手势的能力对象:一次交互取得preview/commit/cancel,再由有效性护栏限制其后续调用。
* 这里只校验调用当刻是否仍有效,不维护手势状态机,也不自行保证commit只能调用一次。
*/
/** A captured pointer may only finish the exact interaction that acquired it. */
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
return Object.freeze({
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
preview(value: T) { if (isCurrent()) actions.preview(value); },
// 提交与取消共用同一有效性判断;原actions负责释放手势与处理重复结束。
commit() { if (isCurrent()) actions.commit(); },
cancel() { if (isCurrent()) actions.cancel(); },
});
+7
View File
@@ -1,3 +1,6 @@
/**
* 分区成员拖放的展示上下文。提供名称、成员计数与当前候选归属变化,供节点显示提示;不负责执行成员迁移。
*/
"use client";
import { createContext, useContext } from "react";
@@ -5,14 +8,18 @@ import { Frame } from "lucide-react";
import type { frameDropFeedback } from "@/lib/canvas-frames";
import styles from "./canvas.module.css";
/** 缺少Provider时使用空展示数据;drops是frameDropFeedback产生的当前预览,不能代替持久归属。 */
export const FrameFeedback = createContext({ names: new Map<string, string>(), counts: new Map<string, number>(), drops: [] as ReturnType<typeof frameDropFeedback> });
/** 优先展示拖动候选to,否则使用既有frameId;只有被选中或有拖放变化时才考虑显示徽标。 */
export function MembershipBadge({ id, frameId, selected }: { id: string; frameId?: string; selected: boolean }) {
const { names, drops } = useContext(FrameFeedback);
const drop = drops.find((item) => item.id === id);
// drop.to可能为空,表示准备移出;不能用“drop.to || frameId”退回原分区,否则会隐藏移出意图。
const target = drop ? drop.to : frameId;
const name = target ? names.get(target) : undefined;
if (!drop && !selected) return 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>;
}
@@ -1,16 +1,25 @@
/**
* 分区名称显示模式的受控单选控件。显示当前值并通过change请求修改,组件本身不拥有分区模型。
*/
"use client";
import { useRef } from "react";
import { EyeOff, Scaling, Type } from "lucide-react";
import type { FrameLabelMode } from "@/lib/canvas-elements";
import styles from "./canvas.module.css";
/** 固定三种模式,数组顺序同时决定视觉滑块位置与键盘导航顺序。 */
const options = [
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称", Icon: EyeOff },
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小", Icon: Type },
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小", Icon: Scaling },
] as const;
/** 鼠标点击切换模式;方向键循环移动,Home/End跳到首尾,并把焦点移至目标按钮。
* radio角色、aria-checked和单一Tab入口表达单选语义;SVG仅提供视觉提示。 */
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
// 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。
const items = useRef<(HTMLButtonElement | null)[]>([]);
const current = Math.max(0, options.findIndex((option) => option.value === value));
// 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。
// 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (delta !== undefined || event.key === "Home" || event.key === "End") {
+17
View File
@@ -1,3 +1,7 @@
/**
* 分区节点:展示名称、边框、成员拖入/移出反馈,以及子画布折叠与概览设置。
* 局部状态只存名称输入草稿;保存与分区属性修改都交给作用域编辑接口,成员归属不由本组件直接变更。
*/
"use client";
import { useContext, useEffect, useRef, useState } from "react";
@@ -13,25 +17,37 @@ import {useCanvasOverlay} from "./canvas-overlays";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
/** directAnchors用于新版分区四向连接点;子画布ports则按输入/输出方向单独布局。
* 分区使用自己的设置浮层,因此隐藏TransformableNode的通用样式栏,仍保留公共变换行为。 */
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
const { directAnchors = false, ...nodeProps } = props;
const { id, data, selected } = nodeProps;
// 设置浮层注册到当前视图的overlay索引,让Portal中的控件也能正确参与焦点与输入路由。
const overlay=useCanvasOverlay(id,"frame-settings");
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const feedback = useContext(FrameFeedback);
// 反馈来自当前拖动预计算,只统计跨分区的进入/离开;这两个计数不是持久成员列表。
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
const { zoom } = useViewport();
const reduce = useReducedMotion();
// 名称是受控输入草稿;外部data.title变化时同步覆盖,实际提交发生在blur。
const [title, setTitle] = useState(data.title);
useEffect(() => setTitle(data.title), [data.title]);
// Escape恢复模型标题并标记跳过随后的blur保存;Enter排除输入法组合态后通过blur统一提交。
const cancelBlur = useRef(false);
const mode = data.labelMode || "fixed";
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
function saveTitle() {
const name = title.trim() || "未命名分区";
if (name !== data.title) editing.frame(id, { title: name });
setTitle(name);
}
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
return <>
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
@@ -59,6 +75,7 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
{directAnchors && <ContentHandles selected={!!selected} />}
{data.ports?.map((port) => {
// 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。
const peers = data.ports!.filter((item) => item.direction === port.direction);
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
})}
+84
View File
@@ -1,3 +1,9 @@
/**
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
*/
"use client";
import { useImageUploads } from "./use-image-uploads";
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
@@ -41,15 +47,21 @@ import type { NodeTransform } from "@/lib/canvas-geometry";
import { MaterialNode, NodeEditing } from "./material-node";
import styles from "./canvas.module.css";
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
const edgeTypes = { default: CanvasEdge };
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
/** ReactFlow type到节点展示组件的固定表,须与makeCanvasNode/投影生成的type保持一致。 */
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
/** 每个挂载对应一个board身份,初始快照/草稿只在useState初始化时采用;切画布应由上层key重挂载。 */
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
const flow = useReactFlow<CanvasNode>();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
const storageKey = canvasStorageKey(board.workspace_id, board.id);
// 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。
// 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。
const [initial] = useState(() => {
try {
const local = readLocalRecovery(localStorage, storageKey, snapshot);
@@ -62,6 +74,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
});
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
const [tool, setTool] = useState<"select" | "hand">("select");
const [error, setError] = useState(initial.error);
const feedback = useContext(FeedbackContext);
@@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const [rotating, setRotating] = useState<string | null>(null);
const [draggedIds, setDraggedIds] = useState<string[]>([]);
const [membershipNotice, setMembershipNotice] = useState("");
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
const dragOrigins = useRef(new Map<string, XYPosition>());
const draggingFrame = useRef(false);
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
const pendingImage = useRef<BranchRequest | null>(null);
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
const closeBranch = useCallback(() => setBranchRequest(null), []);
const imageKind = useRef<"image" | "plain-image">("image");
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const [recovery, setRecovery] = useState(initial.recovery);
// 根恢复待决时暂停保存;tree负责真实owner文档,UI显示节点来自其树投影。子图恢复按Map首项逐个处理。
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
const childRecovery = [...tree.recoveries][0];
/** 下载单份内存文档JSON备份,稍后释放Blob URL;不修改草稿选择,也不保证包含所有子图。 */
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
const sync = tree;
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
const treeRef = useRef(tree); treeRef.current = tree;
// 上传完成回调通过treeRef定位当前归属,并更新当前文档与历史;job.owner可能已不再是最新owner。
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
const imageUploading = demoImageUploading;
// 文件导入等待期间的迁移检查标记;这不是通用导入队列,也不提供并发文件事务锁。
const ioActive=useRef(false);
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
useEffect(() => {
const current = treeRef.current;
for (const job of uploads.jobs) {
@@ -102,38 +124,48 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
}
}, [uploads.jobs,tree.documents]);
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
function dispatch(action: CanvasAction) {
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
treeRef.current.dispatch(action);
}
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
useEffect(() => {
const input = imageInput.current;
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
const cancel = () => { pendingImage.current = null; };
input?.addEventListener("cancel", cancel);
return () => input?.removeEventListener("cancel", cancel);
}, []);
const { nodes: displayNodes, edges } = state.present;
// 对成员任务保护节点关闭拖动/删除/连接;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; });
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示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。
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); } };
// 从场景节点派生分区名称、已有frameId计数与本次拖入/拖出候选,只作显示,不在此改变owner。
const frameFeedback = useMemo(() => {
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
const counts = new Map<string, number>();
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
}, [nodes, draggedIds]);
// 成员提示四秒后清理;重新提示重置计时器,卸载取消。
useEffect(() => {
if (!membershipNotice) return;
const timer = setTimeout(() => setMembershipNotice(""), 4000);
return () => clearTimeout(timer);
}, [membershipNotice]);
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
useEffect(() => {
if (!feedback || !error) return;
const id = `canvas:${board.id}:error`;
notify({ id, level: "error", title: error });
setError("");
}, [feedback, error, board.id]);
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
useEffect(() => {
if (!feedback || !membershipNotice) return;
const id = `canvas:${board.id}:membership`;
@@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return () => dismissNotification(id);
}, [feedback, membershipNotice, board.id]);
const duration = reduceMotion ? 0 : 180;
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
treeRef.current.dispatch({ type: "viewport", viewport });
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
};
/** 工具栏视口动画完成后保存实际视口;错误转界面提示,不提前记录尚未完成的目标值。 */
const changeView = (operation: () => Promise<boolean>) => {
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
};
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
const latest = useRef(state.present);
latest.current = state.present;
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
const select = useCallback((id: string) => treeRef.current.select(id), []);
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
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处理文本值与历史。 */
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
/** 打开分支类型菜单并退出变换模式,point用于屏幕定位。 */
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
/** 将场景位置减owner原点再除scale,宽高也除scale;旋转等其余字段直接交旧transform动作。 */
const transform = useCallback((id: string, transform: NodeTransform) => {
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
}, []);
/** 样式适配只构造旧style动作,具体字段合并由reducer处理。 */
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
/** 节点名称通过owner映射更新,避免将显示复合ID当持久ID。 */
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
/** 分区属性以replace提交;折叠切换从已保存expanded/collapsedSize或默认值恢复尺寸,并写node与style。 */
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
const document = current.documents.get(ref.boardId)!;
@@ -174,7 +217,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
}) } }, ref.boardId);
}, []);
// 注入节点动作及本地旋转/拉伸模式,两模式互斥;旧版接口没有新版手势token租约。
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
/** 在源owner文档创建节点与连线,at从场景坐标转局部;发布后尝试把视口移向新节点,异常留作提示。 */
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
try {
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);
@@ -184,11 +229,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setBranchRequest(null);
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
}
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
function chooseBranch(kind: "text" | "image" | "script") {
if (!branchRequest) return;
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
else createBranch(branchRequest, kind);
}
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
const connectionEnd: OnConnectEnd = (event, connection) => {
const start = connectionStart.current;
connectionStart.current = null;
@@ -201,11 +248,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
};
/** 按窗口中心转换为场景点并加六档偏移,减少连续添加完全重叠;不是按编辑器容器中心计算。 */
const center = () => {
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const offset = (nodes.length % 6) * 18;
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
};
/** 根节点上限500;真实分区走subgraphs命令创建子画布,其它节点本地加入,演示frame也走本地模型。 */
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
const node = makeCanvasNode(kind, center(), src);
@@ -218,6 +267,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
setTool("select");
}
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
function addTemplate() {
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
const position = center();
@@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
] } });
setTool("select");
}
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
@@ -242,6 +293,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
setMembershipNotice("");
}
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "active") return;
const origins = new Map(dragOrigins.current);
@@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (ids.length) {
const positions = new Map(items.map((item) => [item.id, item.position]));
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
// 先生成“加入/移出”展示文案;实际owner由下面的真实子图路径重新判定,成功后才显示结果通知。
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
if (snapshot && tree.rootDocument.schema_version === 2) {
try {
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
const byOwner = new Map<string, { ids: string[]; target: string }>();
const parents = subgraphParents(tree.current.current);
for (const item of items.filter((item) => ids.includes(item.id))) {
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
/** 沿子图父链防环检查,不能把分区入口移动进自己的后代,形成结构循环。 */
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
// 未上传且满足条件的占位图片先走本地迁移;其它节点需要服务端成员事务。
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
tree.movePendingImage(ref.boardId,target,ref.id);continue;
}
@@ -272,6 +329,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
}
}
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
await Promise.all([...byOwner].map(async ([key, group]) => {
let source = key.split("/")[0];
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
@@ -287,10 +345,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
}
}
/** 先把入口端口解析成实际内部节点;同owner本地加边,跨owner优先服务端connections事务,缺能力时尝试暴露端口适配。 */
async function connect(connection: Connection) {
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
if (!source || !target) return;
try {
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
for (let depth = 0; depth <= 8; depth++) {
@@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
throw new Error("subgraph_cycle");
};
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
if (realSource.boardId === realTarget.boardId) {
const document = tree.current.current.get(realSource.boardId)!;
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
@@ -316,11 +377,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return;
}
}
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
const document = tree.current.current.get(exposed.boardId)!;
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
}
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
async function readImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
if (snapshot) {
try { const job = uploads.start(file, (src) => {
const current = treeRef.current;
@@ -343,12 +407,14 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
return {owner:board.id,node};
});
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
const node={...job.node,pendingUpload:job.node.id};
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
setTool("select"); } catch(error) {setError(errorText(error));}
return;
}
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
setImageUploading(true);
try {
const src = await new Promise<string>((resolve, reject) => {
@@ -370,6 +436,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
finally { setImageUploading(false); }
}
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
async function importProject(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
@@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
finally{ioActive.current=false;}
}
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
async function exportProject() {
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; }
@@ -399,7 +469,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
useEffect(() => {
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
const keydown = (event: KeyboardEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
@@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return () => window.removeEventListener("keydown", keydown);
}, []);
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<ImagePreviews.Provider value={imagePreviews}>
@@ -427,6 +504,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false;
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
const linked = removing.filter((node) => node.data.subgraph);
const parents = subgraphParents(tree.current.current);
const referenced = removing.filter((node) => {
@@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
});
if (!linked.length && !referenced.length) return true;
try {
// 分区逐个事务解散,参与者包含父、子、孙及需要调整引用的祖先;并非一批选择全体原子操作。
for (const node of linked) {
const ref = tree.views.nodeRefs.get(node.id)!;
await tree.ensure(node.data.subgraph!.boardId);
@@ -445,6 +524,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
}
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
for (const node of removing.filter((node) => !node.data.subgraph)) {
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
let owner = ref.boardId;
@@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
return false;
}}
onNodesChange={tree.nodesChanged}
@@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
// AIPanel收到owner原始节点,显示ID仅用于NodeToolbar定位;save仍经tree.nodeAction回写owner。
const ref = tree.views.nodeRefs.get(node.id)!;
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
@@ -558,6 +640,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</div>}
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
// 成员任务提示跟随当前source/target中的场景节点,队首同步、后续等待,失败通过retryMembers继续。
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
@@ -577,6 +660,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
);
}
/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
}
@@ -1,3 +1,7 @@
/**
* 新版元素AI工具面板。配置、生成开始、取消和结果采用均通过engine命令进入既有状态与同步链路。
* 任务事实读取runtime.runs/ledger,局部state只保存输入、模型目录和界面错误;关闭面板不等于取消生成。
*/
"use client";
import { useEffect, useState } from "react";
@@ -10,7 +14,10 @@ import { errorText } from "@/lib/api";
import { managedImageSource } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** 只接受非数组JSON对象作为配置/输出包络,其它值退为空对象,避免直接按对象读取标量。 */
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
const saved = object(element.data.ai), root = engine.getSnapshot();
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
@@ -18,14 +25,20 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
const [propagate, setPropagate] = useState(saved.propagate === true);
const image = ['image','plain-image'].includes(element.componentId);
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
const run = relevant.at(-1);
// ledger里的run.start可尚未得到服务端run,必须单独显示等待/阻断,不能仅看runs来判定空闲。
const starts = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation);
const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined;
// 同一run未解决的cancel/apply也占忙碌状态;hasMore/pendingPage表示任务事件尚未完整读取。
const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved');
const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply');
const busy = pending.length > 0 || actions.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
// 展示当前元素最后一个输出事件;是否可采用还要检查output_id、任务冲突状态及已应用集合。
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
// 按当前ownerCanvasId读取可用模型,保留仍兼容的选择,否则选首个available;目录不可用原因单独提示。
// 切作用域/卸载时abort;所有异步结果先检查取消状态,不把旧目录写回新面板。
useEffect(() => {
const controller = new AbortController();setLoading(true);setError('');
engine.models(element.ownerCanvasId,controller.signal).then(catalog => {
@@ -36,13 +49,20 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr
}).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); });
return () => controller.abort();
},[engine,element.ownerCanvasId,image]);
/** 将同步命令的rejected结果显示为错误;返回布尔值供“先保存配置再启动”短路。 */
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
/** 有模型且不忙时提交ai配置;否则不发修改并返回true。该函数只配置,不发起生成。 */
function save() {
if(!model || busy)return true;
return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'}));
}
// 用服务端输出身份判断是否已采用,不凭当前正文/图片内容相同推测结果是否应用。
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]) : '结果将显示在当前元素';
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
return <section data-canvas-ui-view={viewId} className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
@@ -1,3 +1,9 @@
/**
* 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。
* 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。
* 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。
* 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
@@ -40,9 +46,13 @@ import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
import { ImagePreviews } from "./canvas-image";
import styles from "./canvas.module.css";
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
// 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
let before=engine.getSnapshot().document;
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
@@ -52,16 +62,20 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
notify(immediate);
});
}, [engine,sceneCache]);
// rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
if (liveView) lastView.current = liveView;
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
// 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
useEffect(()=>{
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
// Expanded descendants can extend far beyond their partition entry. Initial
@@ -70,7 +84,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
if(!nodes.length)return;
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
},[engine,flow,root.document,view.canvasId]);
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
// 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
// Removing an active renderer need not dispatch a native blur. Retire only
@@ -78,19 +94,26 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
const [copying, setCopying] = useState(false);
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
// 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState("");
// resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
// 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
// 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
// groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
// 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
const projection = useMemo(() => {
const result = scene;
// Preserve renderer measurements across metadata/preview updates. Dropping them
@@ -100,36 +123,50 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
}, [engine, root.document, scene,view.canvasId]);
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
const surfaceBounds=surface.current?.getBoundingClientRect();
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
// 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
// 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
const nativeNodes=useStore(state=>state.nodes);
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
// 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。
// 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
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)));
}, []);
/** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
/** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
/** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
const current = engine.sessions.getSnapshot().views.get(viewId)!;
const ids = new Set(current.selection);
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
}
/** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。
* 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */
function selectAtPane(event: {clientX:number;clientY:number}) {
const captured=current.current;
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
@@ -149,12 +186,16 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const hit=result.hits[0];
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
}
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
function beginTransform(id: Id) {
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().gesture!.token;
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
}
/** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。
* 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
const { root, projection } = current.current, element = root.document.elements.get(id);
if (element?.structure !== 'node') return;
@@ -165,16 +206,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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 } : {}) };
report(engine.sessions.update(viewId, target(id), patch));
}
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
* 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
// 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
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事务。 */
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 } } }); };
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
return id;
}
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */
function connect(connection: Connection) {
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
@@ -182,21 +228,30 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
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。
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解除订阅及显示资源占用。
useEffect(() => {
if (!liveView) return;
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
resourceView.current=view;
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
}, [engine,viewId,Boolean(liveView)]);
// 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
// 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
/** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */
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要提示处理。
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
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其它视图的交互。
useEffect(() => {
/** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
// Bubble after the target/React handlers: an input owner can claim Escape first.
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
const escape = (event: KeyboardEvent) => {
if(event.key!=='Escape')return;
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
@@ -208,10 +263,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
const modes = view.modes;
// Retain explicit owners in the native renderer, leaving the full graph in its
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
for(const entry of visibleOverlays)retained.add(entry.target.id);
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
@@ -221,10 +278,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
if(focusedId)retained.add(focusedId);
// Retained connections also keep their measured endpoint owners alive.
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
// 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
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 canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
@@ -233,15 +292,19 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
// Actions use current.current / engine snapshots instead of captured document projections.
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
// 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
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'); },
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
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'); },
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'); },
// 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。
branch: (id, direction) => {
const node = projectionCache.current!.getNode(id); if (!node) return;
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
@@ -249,19 +312,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
},
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
// 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
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') });
},
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
// 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。
patch: (id, value, transient) => {
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
const path = transformPath((value.path ?? null) as Json, t, true);
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
// 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.path,避免无意重写。
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
const { path: ignored, ...rest } = value; void ignored;
@@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
},
}),[engine,viewId,report]);
// 自定义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]);
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
if (!liveView) return <p role="status">画布已关闭</p>;
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
// 文件选择调用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}
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
@@ -296,6 +370,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
onNodeDragStart={(event, node, nodes) => {
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 point = 'clientX' in event ? event : event.changedTouches[0];
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
@@ -305,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
onNodeDragStop={event => {
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
const saved = drag.current; drag.current = null; pointerStart.current = null;
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
@@ -318,6 +394,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
// 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
setCopying(true); setError("");
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
@@ -339,9 +416,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
</div>;
}
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
const [handoffViewport]=useState(initialViewport);
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
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>;
}
@@ -1,3 +1,7 @@
/**
* 新版画布的导入/复制任务显示入口。订阅engine已发布状态,选择文件后启动runtime任务,并展示已加入前缀、等待项与失败原因。
* 面板不保存独立任务事实;暂停/继续/停止由imports实现,停止后已加入内容仍保留,不能把它当成整批回滚。
*/
"use client";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
@@ -7,15 +11,25 @@ import { IconButton } from "@/components/ui/icon-button";
import { errorText } from "@/lib/api";
import styles from "./canvas.module.css";
/** 将记录级错误码转成可读说明,未识别的码使用通用提示;原始错误仍保存在任务记录中。 */
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
/** 只显示目标canvas最近三个任务,倒序展示;任务数量与汇总仅覆盖这个显示窗口,不代表项目全部导入。 */
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
// React订阅唯一发布根,不在组件里复制任务状态;同一个getSnapshot也作为服务端快照入口。
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
/** 统一承接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)); }); };
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
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>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
@@ -1,3 +1,7 @@
/**
* 新版画布的同步冲突处理界面。取得engine提供的恢复会话,按操作逐项选择保留/放弃,先预览再应用。
* 真正的冻结、回执核对、重放及备份由runtime完成;面板只保存选择与方案展示,不自行覆盖当前文档。
*/
"use client";
import { useEffect, useRef, useState } from 'react';
@@ -7,6 +11,7 @@ import type { RecoverySession } from '@/lib/canvas-runtime/engine';
import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog';
/** 按恢复错误分类说明需要等待、重新选择或更新解释器;未知错误保留通用重试提示。 */
function message(error:unknown){
const code=(error as {code?:string;message?:string})?.code??(error as Error)?.message;
if(code==='recovery_waiting_for_receipts')return '还有请求正在确认。请关闭此窗口,重试同步后再处理。';
@@ -30,19 +35,24 @@ function message(error:unknown){
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
}
/** undefined明确显示未设置,字符串直接显示,其它JSON序列化;超过240字符可展开全文。
* React文本节点负责转义内容,不将导入内容或服务端值作为HTML执行。 */
function PreviewValue({value}:{value:Json|undefined}){
if(value===undefined)return <span>未设置</span>;
const text=typeof value==='string'?value:JSON.stringify(value);
return text.length>240?<details><summary>{text.slice(0,240)}…(展开全文)</summary><pre style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{text}</pre></details>:<span>{text}</span>;
}
/** 仅对常见字段提供具体中文名,其它data字段与结构关系使用类别名,不改变字段寻址规则。 */
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
/** 创建/删除/替换类变更并排显示服务端当前记录与本地after;字段变更由主列表另行渲染。 */
function StructureReview({change,document}:{change:Exclude<Change,{kind:'field'}>;document:Document}){
const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区';
const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id;
return <div style={{fontSize:13,overflowWrap:'anywhere',marginTop:8}}><strong>{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)}</strong>
<div>服务端:{current?<PreviewValue value={current as unknown as Json}/>:<span>不存在</span>}</div><div>本地请求:{change.after?<PreviewValue value={change.after as unknown as Json}/>:<span>删除此{kind}</span>}</div></div>;
}
/** 展示方案内结构影响:记录创建删除,以及owner/端点/路径字段;统计删除数量便于核对关联范围。 */
function PlanStructureReview({plan}:{plan:ConflictPlan}){
const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field));
if(!structural.length)return null;
@@ -52,17 +62,29 @@ function PlanStructureReview({plan}:{plan:ConflictPlan}){
</details>;
}
/** owner保存清理时要取消的会话,session用于显示;records按操作序列展示并排除上传任务。
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
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[]>([]);
// 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);
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
useEffect(()=>{
let live=true;
Promise.resolve().then(()=>live?engine.prepareRecovery():undefined).then(async session=>{if(!session)return;if(!live){await session.cancel();return;}owner.current=session;setSession(session);setRecords([...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>session.pendingOperationIds.includes(record.intent.id)&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence).map(record=>record.intent));}).catch(error=>{if(live)setError(message(error));}).finally(()=>{if(live)setBusy(false);});
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
},[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);}}
/** 有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);}}
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
const canonical=session?.canonical.document;
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent data-canvas-ui-view={viewId} aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
<DialogTitle>处理同步修改</DialogTitle>
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
@@ -1,3 +1,7 @@
/**
* 从同工作区已打开的其它项目中发现可复用图片,授权接纳资源后在目标画布创建新图片元素。
* 面板不重新上传原文件;资源接纳与元素创建的事务、持久化和授权由engine.adoptResource负责。
*/
"use client";
import { useEffect, useId, useRef, useState } from "react";
@@ -9,27 +13,37 @@ import type { Id, Point } from "@/lib/canvas-core/types";
import { fault } from "@/lib/canvas-core/values";
import { errorText } from "@/lib/api";
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
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取得资源元数据,不能信任缩略图即代表可访问。 */
type Candidate = { id: Id; title: string };
/** position是打开面板时捕获的画布坐标,插入图片以此为中心;onAdded让调用方选择新元素。 */
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
// controller取消本次面板操作,requesting同步防重复点击,busy用于禁用按钮和展示当前候选进度。
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
const dismiss=()=>{lifecycle.current?.abort();close();};
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
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(()=>{
const controller=new AbortController();lifecycle.current=controller;
const identity=host.identity(),workspace=host.getWorkspaces().get(workspaceId);
void (async()=>{
if(!workspace?.isCurrentIdentity()||workspace.accountId!==identity.accountId)throw fault('identity_changed');
// 单个项目打开失败不阻断其它候选,allSettled保留每项结果;排除本项目避免重复从自身取资源。
const results=await Promise.allSettled([...workspace.getProjects()].filter(([id])=>id!==engine.getSnapshot().document.projectId).map(([,project])=>project));
if(controller.signal.aborted)return;
if(!workspace.isCurrentIdentity()||host.identity().authEpoch!==identity.authEpoch)throw fault('identity_changed');
// 按资源ID去重,排除本项目已有资源;其它项目若有本地未stored资源也跳过,避免复用未可靠保存的文件。
const candidates=new Map<Id,Candidate>(),owned=engine.getSnapshot().runtime.resources;
for(const result of results)if(result.status==='fulfilled'){
const snapshot=result.value.getSnapshot();
for(const element of snapshot.document.elements.values()){
const id=element.data.assetId;
if(element.structure!=='node'||typeof id!=='string'||!id||owned.has(id)||candidates.has(id)||snapshot.runtime.resources.has(id)&&!snapshot.runtime.resources.get(id)!.stored)continue;
// 只从明确声明assetId资源字段的组件提取,不能把任意同名业务字段当图片资源。
if(!result.value.editor.registry.components.get(element.componentId)?.resourceFields?.includes('assetId'))continue;
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
}
@@ -38,6 +52,8 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
},[engine,host,workspaceId]);
/** 一次只接纳一个候选。生成新元素ID,在adoptResource提供的事务内校验目标并创建图片。
* 失败保持面板以便重试;关闭或卸载后不再更新错误/选中状态,取消的实际边界由engine处理。 */
async function insert(item:Candidate){
const signal=lifecycle.current?.signal;if(!signal||signal.aborted||requesting.current)return;
requesting.current=true;
@@ -45,15 +61,19 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
try{
const id=crypto.randomUUID(),result=await engine.adoptResource(item.id,(resource,tx)=>{
const document=tx.getDocument(),canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;
// 父入口ID变化或原入口incarnation变化都拒绝,避免资源授权等待期间目标已经重建。
if(!canvas||canvas.parent?.entryElementId!==target.canvas?.parent?.entryElementId||target.entry&&(entry?.id!==target.entry.id||entry.incarnation!==target.entry.incarnation))throw fault('asset_target_changed');
// 按原始宽高等比装入260×200边界,再以position为中心计算左上角;小图也可能被放大。
const scale=Math.min(260/resource.asset.width,200/resource.asset.height),width=resource.asset.width*scale,height=resource.asset.height*scale;
tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:canvasId,geometry:{x:position.x-width/2,y:position.y-height/2,width,height,rotation:0},data:{title:item.title,text:'',assetId:resource.id,src:resource.src}}});
},{signal});
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
if(result.status==='rejected')throw fault(result.code,result.targets);
if(!signal.aborted){onAdded(id);close();}
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
}
// Dialog关闭入口都走dismiss;缩略图使用工作区资源预览接口,标题截断仅影响显示。
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
+10
View File
@@ -1,11 +1,21 @@
/**
* 图标目录数据与搜索入口。JSON保存名称、中文标签、关键词及分类等检索元数据;SVG路径另由图标加载器取得。
*/
import records from "./icon-catalog-data.json" with { type: "json" };
/** 分类展示顺序;common/all/other是搜索时有特殊含义的选项,其余按categories成员匹配。 */
export const iconCategories = [
["common", "常用"], ["all", "全部"], ["arrows", "箭头与导航"], ["people", "人物与表情"], ["office", "文件与办公"], ["media", "媒体与设计"], ["tech", "科技与设备"], ["business", "图表与商业"], ["nature", "自然与天气"], ["travel", "出行与地点"], ["shapes", "形状与符号"], ["other", "其他"],
] as const;
/** 导出静态记录数组供选择器统计与筛选,不在运行时下载整套图标路径。 */
export const iconCatalog = records;
/** 按图标name建Map供调用方直接定位记录;与目录共享记录对象,不复制或冻结内容。 */
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
/** 来源过滤支持全部、Lucide和Tabler;分类与来源是独立条件。 */
export type IconSource = "all" | "lucide" | "tabler";
/** 去掉首尾空白、连字符、下划线及空白并转小写,再在名称+标签+关键词中做子串匹配。
* 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。
* 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
+5
View File
@@ -1,9 +1,14 @@
/**
* 图标节点的轻量显示装配。图标名称与颜色来自节点data,交互与几何变换委托公共节点容器。
*/
"use client";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { CanvasIcon } from "./canvas-icon";
import styles from "./canvas.module.css";
/** 前景色默认棕色、背景默认透明;SVG填满容器并用节点title命名,实际路径由CanvasIcon异步加载。
* 此处的100%宽高跟随外层尺寸;固定strokeWidth传给图标层,不在本组件修改节点几何。 */
export function IconNode(props: NodeProps<CanvasNode>) {
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><CanvasIcon name={props.data.icon} aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
}
+16
View File
@@ -1,3 +1,7 @@
/**
* 图标选择面板。按来源、分类与文本查询筛选目录,使用固定五列虚拟列表只挂载视窗附近的图标按钮。
* 仅保存搜索/滚动/焦点状态;choose把名称和标签交给画布创建节点,实际SVG按需加载。
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
@@ -7,7 +11,9 @@ import { CanvasIcon } from "./canvas-icon";
import { iconCatalog, iconCategories, searchIcons, type IconSource } from "./icon-catalog";
import styles from "./canvas.module.css";
/** 固定网格参数:每行五项、每行48px;占位总高与绝对定位必须使用同一组参数。 */
const columns = 5, rowHeight = 48;
/** 通过Portal挂载到body,调用方在浏览器中打开;Escape或关闭按钮只触发close。 */
export function IconPicker({ close, choose }: { close: () => void; choose: (name: string, label: string) => void }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("common");
@@ -18,7 +24,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
const search = useRef<HTMLInputElement>(null);
const grid = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
// 有非空查询时搜索当前来源的全部分类;无查询才使用分类按钮值,useMemo避免无关渲染重复过滤。
const matches = useMemo(() => searchIcons(query, query.trim() ? "all" : category, source), [query, category, source]);
// 挂载时聚焦搜索框并观察列表实际高度;卸载断开观察器,尝试把焦点还给打开前的元素。
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
search.current?.focus();
@@ -26,11 +34,15 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
if (grid.current) observer.observe(grid.current);
return () => { observer.disconnect(); previous?.focus(); };
}, []);
// 任一筛选条件变化都回到列表顶部与首项焦点索引,避免沿用上次结果中的越界位置。
useEffect(() => { if (grid.current) grid.current.scrollTop = 0; setScrollTop(0); setFocused(0); }, [query, category, source]);
// 根据总行数撑起滚动区域,上下各多挂载一行缓冲;slice只取可见范围的目录项。
const rows = Math.ceil(matches.length / columns);
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
const visible = matches.slice(firstRow * columns, lastRow * columns);
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
function focusIcon(index: number) {
const next = Math.max(0, Math.min(matches.length - 1, index));
setFocused(next);
@@ -42,6 +54,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
}
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
}
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
return createPortal(<motion.aside initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
<label className={styles.iconSource}>图标库<select aria-label="图标库" value={source} onChange={(event) => setSource(event.target.value as IconSource)}><option value="all">全部图标库</option><option value="lucide">Lucide · 2313</option><option value="tabler">Tabler · 24 个试用</option></select></label>
@@ -49,6 +64,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
<div className={styles.iconCategories} aria-label="图标分类">{iconCategories.map(([id, label]) => <button key={id} aria-pressed={!query.trim() && category === id} onClick={() => { setCategory(id); setQuery(""); }}>{label}</button>)}</div>
<div ref={grid} className={styles.virtualIconGrid} aria-label="图标列表" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
<div style={{ height: rows * rowHeight, position: "relative" }}>{visible.map(({ name, label, source }, offset) => {
// 加回首行偏移取得完整结果索引,保证滚动后的定位、Tab状态与键盘导航仍对应同一条记录。
const index = firstRow * columns + offset;
return <button key={name} type="button" data-index={index} tabIndex={focused === index ? 0 : -1} title={`${label} · ${source === "tabler" ? "Tabler" : "Lucide"}`} aria-label={`添加${label}图标`} style={{ position: "absolute", width: "20%", height: rowHeight, left: `${index % columns * 20}%`, top: Math.floor(index / columns) * rowHeight }} onFocus={() => setFocused(index)} onClick={() => choose(name, label)}
onKeyDown={(event) => { const delta = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -index, End: matches.length - 1 - index }[event.key]; if (delta !== undefined) { event.preventDefault(); focusIcon(index + delta); } }}><CanvasIcon name={name} size={24} /></button>;
@@ -1,3 +1,7 @@
/**
* 旧画布迁往canonical模型的界面协调层。持有本页旧编辑租约,迁移前停止新编辑并排空旧writer,结果确认后重开新版。
* “升级”指数据与协议迁移;本地记录和浏览器锁用于跨页协调,服务端版本校验与幂等仍由迁移服务负责。
*/
"use client";
import {useEffect,useRef,useState} from 'react';
import type {Registry} from '@/lib/canvas-core/types';
@@ -6,21 +10,33 @@ import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type
import {errorText} from '@/lib/api';
import styles from './canvas.module.css';
/** 将迁移错误码翻成可操作提示,尤其区分确定拒绝与结果未知;未知结果不能当成没写入而直接开放旧编辑。 */
function message(error:unknown){
const code=error instanceof Error?error.message:String(error);
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
}
/** hold控制旧编辑入口;prepare排空指定根的保存工作,check检查当前是否允许迁移。
* canLeave保护重开页面前的未同步内容,exportDraft提供本页备份,context提供账户与迁移根身份。 */
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
/** 只保存迁移展示状态,实际请求/本地记录由createBrowserCanvasMigration管理。
* attempt是同步防重入标记,running只用于渲染,finished表示本页已进入成功或外部变更后的交接阶段。 */
export function LegacyCanvasMigration(props:Props){
// 回调经ref读取最新props,避免每次父组件渲染都重新申请共享旧编辑租约。
const port=useRef(props);port.current=props;
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
// lease是本页共享旧编辑锁;life取消本次挂载工作;previous用来识别其它页面删除待确认记录的变化。
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
// localStorage迁移记录按账户+工作区隔离,canvasId用于判断该任务是否阻断当前画布。
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
const refresh=useRef<()=>Promise<void>>(async()=>{});
// 挂载或作用域变化时检查记录并申请旧编辑租约;身份失效或卸载后不再采用异步结果。
useEffect(()=>{
const controller=new AbortController();life.current=controller;
/** 同时检查本页取消与账户身份,不能只因旧请求返回就更新当前页面或解锁编辑。 */
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
const read=()=>readCanvasMigration(localStorage,scope);
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
async function inspect(){
if(attempt.current||!current())return;
setChecking(true);
@@ -29,9 +45,11 @@ export function LegacyCanvasMigration(props:Props){
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
}
// 已持有共享锁时复用;申请新锁前先暂停编辑,避免没有锁的空档接收修改。
if(lease.current){port.current.hold(false);setError('');return;}
port.current.hold(true);
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
// 等锁期间页面可能卸载或换账户,拿到过期锁立即释放,不再发布可编辑状态。
if(!current()){await acquired.release();return;}
lease.current=acquired;port.current.hold(false);setError('');
}catch(error){
@@ -39,6 +57,7 @@ export function LegacyCanvasMigration(props:Props){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
// Without Web Locks this browser cannot upgrade, but existing legacy
// editing remains supported unless there is an unresolved identity job.
// 不支持Web Locks仅禁止安全迁移;没有阻断记录时仍允许旧编辑,不能把这类环境错误当数据已迁移。
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
}
@@ -46,6 +65,8 @@ export function LegacyCanvasMigration(props:Props){
}finally{if(current())setChecking(false);}
}
refresh.current=inspect;void inspect();
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
const storage=(event:StorageEvent)=>{
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
// Never discard another view's in-memory content because a record vanished.
@@ -56,10 +77,13 @@ export function LegacyCanvasMigration(props:Props){
void inspect();
};
window.addEventListener('storage',storage);
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
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.
// eslint-disable-next-line react-hooks/exhaustive-deps
},[key,props.canvasId]);
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
async function upgrade(){
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
attempt.current=true;setRunning(true);setError('');
@@ -67,6 +91,7 @@ export function LegacyCanvasMigration(props:Props){
let failure='';
try{
const pending=readCanvasMigration(localStorage,scope);
// 工作区已有另一根pending时禁止挤占;同一根结果未知则继续原请求查询,不重复做初始准备。
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
if(pending?.phase!=='pending')port.current.check();
port.current.hold(true);
@@ -75,14 +100,17 @@ export function LegacyCanvasMigration(props:Props){
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
// start独占阶段再次check/prepare/check,覆盖共享锁释放到独占锁取得之间可能出现的旧写入。
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
if(!current())return;
// 只有确认applied或已迁移才通知外层重开;拒绝/未知保留记录,交finally重新决定是否能恢复旧编辑。
if(result?.status==='applied'||result?.status==='already-migrated'){
finished.current=true;port.current.context.onApplied();return;
}
if(result&&'error'in result){failure=message(result.error);setError(failure);}
const next=migration.read();previous.current=next;setJob(next);
}catch(error){if(current()){failure=message(error);setError(failure);}}
// 不论尝试结果都清重入标记;仅当前身份有效才更新UI,并从持久记录重新判断是否仍需hold。
finally{
attempt.current=false;
if(current()){
@@ -92,8 +120,11 @@ export function LegacyCanvasMigration(props:Props){
}
}
}
// pending表示本页在迁移范围内;other表示工作区另一画布待确认,按钮禁用但不篡改其任务。
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
// 入口依次呈现检查中/执行中/外部变更重开/开始或继续迁移;旧撤销记录不迁移,提示写在按钮title。
// 本页被hold时提供备份;外部变化后的重开仍须check及canLeave,不能直接丢弃尚未同步的内存内容。
return <div className={styles.migrationPanel} aria-label="画布升级">
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
@@ -1,3 +1,7 @@
/**
* 模型读取前缀的只读预览。直接把已校验document投影给ReactFlow,允许浏览视口,不创建可写engine或临时history/ledger。
* 这个组件不负责持久化前缀;后续可编辑partial engine的建立与交接由路由及runtime处理。
*/
"use client";
import {useEffect,useMemo,useRef} from 'react';
@@ -11,26 +15,36 @@ import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/c
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
import styles from './canvas.module.css';
/** 已知类型缺少专用preview renderer时展示准备占位;保留已投影连线需要的Handle,禁止新连接。 */
function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
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;
}
/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
* writable engine is constructed, and no partial model enters durable storage. */
/** renderer catalog限制加载期可用渲染路径;无法解释引用关系时沿用opaque占位,不能猜测缺失内容。 */
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
// Without a declared reference schema, use the existing opaque placeholder
// until the complete document is available to the installed renderer.
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
const projection=useMemo(()=>{
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
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。
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'&&registry.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]);
// 普通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]);
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
<CanvasRendererCommandsContext.Provider value={commands}>
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
+14
View File
@@ -1,3 +1,7 @@
/**
* 文本、脚本、图片卡片共用的节点组件。组合标题、内容、通用变换容器与四向连线入口。
* 编辑通过作用域动作接口发出,不直接写文档;旧连线的无名连接点仍保留用于兼容定位。
*/
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
@@ -14,16 +18,26 @@ import styles from "./canvas.module.css";
import { TransformableNode } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
/** 方向字符串到ReactFlow几何方位的映射,与nodeDirections的稳定Handle ID配套。 */
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
/** 四向连接入口的中文可访问名称,不参与端点数据编码。 */
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
/** 按kind选择图片或文本编辑器,并显示对应标题图标;selected影响样式及连接操作入口。
* 指针点击连接点可打开分支菜单,拖动则留给ReactFlow创建连接。 */
export function MaterialNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
// Headless目标携带元素生命周期与归属;旧渲染回调经过作用域检查,避免迟到事件编辑已替换的对象。
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const { zoom } = useViewport();
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
// 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。
const pointerStart = useRef({ x: 0, y: 0 });
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
// 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。
// 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} renderedTarget={data.__headlessTarget} title={data.title} selected={selected} /></header>
+25
View File
@@ -1,3 +1,8 @@
/**
* 节点组件与画布控制器之间的动作上下文。Provider实现文本、名称、样式、几何、分区及编辑模式修改。
* useScopedNodeEditing为新版渲染回调追加目标身份/归属检查,旧版无Overlay上下文时沿用原接口。
* 本模块不拥有文档、history或队列;它只把界面动作交给正确的控制器。
*/
"use client";
import {createContext,useContext,useMemo} from 'react';
import type {XYPosition} from '@xyflow/react';
@@ -7,39 +12,59 @@ import type {NodeTransform} from '@/lib/canvas-geometry';
import type {EditingLease} from './editing-lease';
import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types';
import {CanvasOverlays} from './canvas-overlays';
/** 默认动作为空实现,便于节点展示独立挂载;可选手势钩子兼容旧版调用与新版EditingLease。
* rotating/resizing是Provider提供的当前模式标记,workspace用于构造导航,schema区分适配路径。 */
const defaultEditing = {
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
endEditing: undefined as undefined | ((id: string) => void),
workspace: "", schema: 1 as 1 | 2,
// begin/select/edit分别供编辑会话、选择与正文更新使用;branch取得屏幕坐标以定位分支菜单。
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
// 几何、文字样式、标题和分区属性分别走独立动作,避免组件直接合并整份节点数据。
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
// 传元素id开启相应模式,null请求退出;是否确属当前元素在作用域包装中进一步检查。
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
};
/** 这些生命周期钩子可缺省,其余基础动作保留默认接口形状。 */
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
/** 由具体画布注入实现;默认上下文不会自行创建一个编辑器实例。 */
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
/** Bind legacy renderer actions to the lifetime and placement that rendered them. */
/** 捕获本次渲染目标的incarnation、ownerCanvasId与视图canvasId,再为每次动作读取最新状态。
* 捕获值代表界面当时展示的对象,最新值用来拒绝过期回调;不能只凭相同元素id就认为仍是同一对象。 */
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
// 只有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;
// 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。
return useMemo(()=>{if(!scope)return editing;
// 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。
// 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);};
// finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/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,
begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);},
edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);},
style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);},
transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);},
// 退出模式用null表示,但只准退出本节点当前拥有的rotate/resize模式,避免关闭其它节点的模式。
rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);},
};
// 保持可选钩子的存在性:失效时beginTransform返回false,其余动作静默忽略。
// begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
// 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
return scoped;
},[editing,scope,id,incarnation,owner,canvas]);
}
+8
View File
@@ -1,3 +1,6 @@
/**
* ReactFlow节点内部几何更新的Hook适配。将同一个store内的更新交给共享批处理器合并,卸载时释放节点租约。
*/
"use client";
import {useCallback,useEffect,useRef} from 'react';
@@ -6,14 +9,19 @@ import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
// useStoreApi wraps the API per hook. Its shared getState function identifies the
// actual store, separating canvases even when they render the same project.
/** WeakMap按实际store的getState函数区分画布;不按projectId共享,避免多个视图误用另一视图的更新器。 */
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
/** 返回稳定的invalidate回调;注册尚未建立或已卸载时调用无效,不直接触发布局读取。 */
export function useNodeInternalsUpdate(id:string){
// useStoreApi的包装对象可能每次不同,稳定getState函数作为store身份;update交给批处理器实际刷新。
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
useEffect(()=>{
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
const owner=batch.acquire(id);lease.current=owner;
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
},[scope,update,id]);
// 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。
return useCallback(()=>lease.current?.invalidate(),[]);
}
@@ -1,3 +1,6 @@
/**
* 选中节点的样式浮层。把颜色、文字排版、背景与旋转/拉伸模式转换成作用域编辑动作,不在此保存样式副本。
*/
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@@ -11,17 +14,25 @@ import {useScopedNodeEditing} from "./node-editing";
import {canvasKeyContext} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
/** NodeToolbar负责定位,AnimatePresence负责选中/取消选中时的进出动画,减少动态效果时关闭位移与时长。
* 浮层注册到overlay索引,保证Portal中的输入也能归属于正确视图与元素。 */
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
const overlay=useCanvasOverlay(id,"node-style");
const reduce = useReducedMotion();
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const style = data.textStyle || {};
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
// 其它按键留给当前控件并阻断全局快捷键;Escape让输入上下文先决定是否可用于退出模式。
if(event.key!=="Escape"){event.stopPropagation();return;}
if(event.defaultPrevented||event.nativeEvent.isComposing||canvasKeyContext(event.nativeEvent).textEditing)return;
// The registered overlay owns its mode dismissal; global shortcuts stay out.
+10
View File
@@ -1,18 +1,28 @@
/**
* 节点标题的局部编辑交互。展示态与输入态切换,提交时调用rename;本地value只是输入草稿。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
/** 双击先选择节点再编辑,键盘F2/Enter也可进入;选中时标题才加入正常Tab顺序。
* renderedTarget把后续rename绑定到当前渲染的元素生命周期与位置。 */
export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
const actions = useScopedNodeEditing(id,renderedTarget);
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
// cancelled用于Escape触发blur时抑制保存;input用于进入编辑后聚焦并选中全部文字。
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
// 展示态跟随外部标题,输入态保留当前草稿;失去选择会退出编辑,进入编辑则集中处理焦点。
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
function save() {
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
cancelled.current = false; setEditing(false);
}
// Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。
// 标题长度限制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}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
return <span className={styles.nodeTitle} title="双击编辑名称" role="button" tabIndex={selected ? 0 : -1} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }}
@@ -1,3 +1,6 @@
/**
* 无卡片标题的图片节点。用通用变换容器承载图片,随视口缩放选择预览或正式资源地址。
*/
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
@@ -5,8 +8,12 @@ import { useViewport, type NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
/** 依赖视口zoom估算图片在屏幕上的像素宽度;CanvasImage负责本地预览与正式图片的显示交接。 */
export function PlainImageNode(props: NodeProps<CanvasNode>) {
const { zoom } = useViewport();
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
// 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>;
}
@@ -1,3 +1,6 @@
/**
* 纯文本节点的显示装配。外层复用节点变换与选择交互,内层使用文本编辑器的plain模式;不在此持有第二份文本数据。
*/
"use client";
import type { NodeProps } from "@xyflow/react";
@@ -5,6 +8,7 @@ import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { CanvasTextEditor } from "./canvas-text-editor";
/** NodeProps整体交给TransformableNode;文本编辑器取得同一个id/data/selected,plain决定其纯文本呈现方式。 */
export function PlainTextNode(props: NodeProps<CanvasNode>) {
return (
<TransformableNode {...props}>
@@ -1,15 +1,25 @@
/**
* ReactFlow适配层的保留标记缓存。为需要保留的节点/边复制一份带__endoleapRetain的投影记录,原对象不变。
* 标记供应用固定版本的编译扩展使用,不是模型属性,也不应进入history、存储或业务renderer数据。
*/
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
* They never enter the document, history, persistence or renderer business data. */
/** 每个投影使用者创建一份缓存:WeakMap复用同一原对象的带标记副本,previous复用最近一次整个输出。 */
export function createRetainedProjection(){
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
const tagged=new WeakMap<object,object>();
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
if(!ids.size){previous=undefined;return{nodes,edges};}
// 数组引用及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[]};
/** 未命中的记录保留原引用;命中时浅复制顶层加标记,嵌套数据仍与原投影共享。 */
const retain=<T extends {id:string}>(row:T):T=>{
if(!ids.has(row.id))return row;
let value=tagged.get(row);if(!value){value={...row,__endoleapRetain:true};tagged.set(row,value);}return value as T;
};
// 非空集合下会映射两张数组,即使没有命中也生成数组;下一次相同输入再复用该result。
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
previous={nodes,edges,ids:new Set(ids),result};return result;
};
+6
View File
@@ -1,5 +1,11 @@
/**
* 将节点文本数据转换为React内联CSS的纯函数。统一卡片与纯文本的默认外观,不修改原数据或编辑状态。
*/
import type { CSSProperties } from "react";
import type { CanvasNodeData } from "@/lib/canvas-elements";
/** 字号优先data.fontSize,纯文本默认24、卡片默认13;布尔样式映射为明确的CSS值。
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
export function textAppearance(data: CanvasNodeData): CSSProperties {
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,
@@ -1,3 +1,7 @@
/**
* 节点共用的几何交互容器:绘制旋转后的内容、选择样式、拉伸手柄及模式提示,协调一次手势的预览/提交/取消。
* 拖动节点位置仍由外层ReactFlow处理;此处主要处理旋转与尺寸变化,通过NodeEditing而非直接写文档。
*/
"use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
@@ -17,8 +21,12 @@ import {useNodeInternalsUpdate} from "./node-internals";
export {NodeEditing} from "./node-editing";
import {useScopedNodeEditing} from "./node-editing";
/** 保存手势起始节点快照与画布坐标,防止每帧以已预览结果为基准累积误差;pointerId/capture限定事件所有权。
* 新版lease绑定具体交互,旧版则使用上下文的begin/transform/finish/cancel适配。 */
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
/** children决定内容显示,hideStyleToolbar允许分区使用专属设置栏;className补充类型外观。
* 能否缩放/旋转由elementCapabilities和当前选中/模式共同决定,不在每个节点组件重复实现。 */
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useScopedNodeEditing(id,data.__headlessTarget);
@@ -27,6 +35,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const { zoom } = useViewport();
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
// 用ref让只挂载一次的window blur监听调用最新cancelGesture;失焦和卸载均取消尚未结束的交互。
const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture();
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
const rotation = data.rotation || 0;
@@ -34,12 +43,16 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const capabilities = elementCapabilities(data.kind);
const resizing = capabilities.resizable && editing.resizing === id && selected;
const rotating = capabilities.rotatable && editing.rotating === id && selected;
// 几何、旋转或选中变化后请求批量更新ReactFlow连接点内部数据,避免端点仍留在旧位置。
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
// 仅在变换模式启用时监听外部点击与Escape;退出/切换模式拆除监听。
useEffect(() => {
if (!rotating && !resizing) return;
/** 点击容器与对应模式按钮之外退出模式;事件用捕获阶段观察,不要求控件把事件冒泡出来。 */
const outside = (event: globalThis.PointerEvent) => {
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
};
/** 按统一输入路由判断Escape是否归工具,尊重文本/组合输入与已处理事件;认领后取消手势并退出模式。 */
const escape = (event: KeyboardEvent) => {
if(event.key!=="Escape")return;
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}});
@@ -50,6 +63,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
}, [rotating, resizing, id, editing]);
/** 主指针左键启动;旋转跳过文本输入目标,重复指针不得接管已有手势,另一指针到来先取消。
* 取得起始节点和租约后捕获指针;捕获失败按取消路径恢复,成功才阻止默认行为与冒泡。 */
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
if (event.button !== 0||event.defaultPrevented) return;
if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return;
@@ -57,6 +72,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
if(event.isPrimary===false)return;
const node = flow.getNode(id);
if (!node) return;
// false表示动作层拒绝启动;缺少专用beginTransform时回退旧版begin,随后按需选中节点。
const lease = editing.beginTransform?.(id); if (lease === false) return;
if (!editing.beginTransform) editing.begin(id);
if (!node.selected) editing.select(id);
@@ -64,6 +80,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
event.preventDefault();event.stopPropagation();
}
/** 只接受持有捕获的指针,把屏幕坐标变为画布坐标,再基于起始快照算旋转或尺寸变化。
* 旋转Shift吸附由geometry函数处理;八向拉伸模式把Shift传入比例约束,普通角落拖动沿用默认行为。 */
function move(event: PointerEvent<HTMLElement>) {
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return;
@@ -72,8 +90,11 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const change = direction === "rotate"
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
// 新版只预览当前lease,旧适配发transform;历史单元何时发布由Provider控制。
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
}
/** 无事件参数可由失焦/卸载强制取消;有事件时先校验捕获归属,避免其它指针干扰。
* 优先lease.cancel,其次cancelTransform;最旧接口缺少取消钩子时把起始几何写回,最后释放捕获。 */
function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
if(event&&gesture.current)event.stopPropagation?.();
if(event&&!ownsPointerCapture(gesture.current,event))return;
@@ -82,12 +103,16 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
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);
}
/** 抬起时先采用最后一个指针位置,再清手势引用并提交;随后释放捕获不会误取消已结束交互。 */
function finish(event: PointerEvent<HTMLElement>) {
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
releasePointerCapture(current.capture,current.pointerId);
}
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
return <>
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
@@ -104,11 +129,13 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
<Grip size={14} />
</button>}
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
// 从方向名推导0%/50%/100%锚点:边中点只占一个方向,角点同时包含横纵方向。
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
onKeyDown={(event) => {
// 键盘每次方向键形成一次完整变换:默认一个viewScale单位,Ctrl/Meta放大十倍,Shift固定比例。
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;
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);
@@ -122,6 +149,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
</>;
}
/** 受控拉伸模式开关;aria-pressed反映当前节点是否处于模式,data标记供外部点击判定排除自身。 */
export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
const editing = useScopedNodeEditing(id,renderedTarget);
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
@@ -1,13 +1,22 @@
/**
* 兼容占位组件:显示可理解的公共几何与说明文字,不解析未知类型的业务payload。
* 原始数据由模型保留;这些组件只提供占位绘制,不执行数据迁移,也不自行决定外层是否允许移动或选择。
*/
"use client";
import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react";
import type {ReactFlowProjectionNode as ProjectionNode} from "@/lib/canvas-bindings/react-flow";
import type { CanvasEdgeModel } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** 将投影提供的四边方位转换成ReactFlow Handle要求的枚举。 */
const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left};
/** Render only the known envelope and derived endpoints, never interpret opaque payload fields. */
/** 使用投影已提供的标题、旋转和fallback元数据绘制占位节点,尺寸填满外层节点容器。
* renderer-unavailable明确表示当前缺少显示实现,其它情形提示原内容保留。
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
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="暂不支持编辑的元素">
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
@@ -15,6 +24,8 @@ export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
{fallback?.handles.map(handle=><Handle key={handle.id} id={handle.id} type="source" position={positions[handle.side]} className={styles.centerHandle} isConnectable={false} isConnectableStart={false} isConnectableEnd={false} aria-hidden />)}
</div>;
}
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
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>;
+26
View File
@@ -1,10 +1,16 @@
/**
* 旧画布树的AI任务跟踪Hook。发现根及后代画布的活动任务,按事件游标增量读取,协调结果刷新与本地冲突显示。
* 此路径使用内存Map/Set和定时轮询,不具备新版runtime ledger的持久请求恢复;卸载会停止本Hook跟踪。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { CANVAS_ACCEPT } from "@/lib/canvas-v2";
import { canvasPath } from "@/lib/canvas-sync";
import type { useCanvasTree } from "./use-canvas-tree";
/** 服务端模型目录条目:输入/输出模态、可用性与可选参数schema;本Hook只传递模型选择。 */
export type CanvasModel = { id: string; label?: string; display_name?: string; output_modalities: string[]; input_modalities: string[]; available: boolean; unavailable_reason?: string; parameters_schema?: unknown };
/** 旧AI接口的任务分页结果;next_seq/has_more跟踪事件页,steps/outputs/events供面板和活动连线显示。 */
export type AIRun = {
run_id: string; board_id: string; status: string; next_seq: number; has_more?: boolean; error?: string;
steps?: { step_id: string; board_id: string; node_id: string; status: string; error?: string }[];
@@ -12,7 +18,9 @@ export type AIRun = {
events?: { seq: number; type: string; board_id?: string; node_id?: string; logical_edge_id?: string; canvas_version?: number; output?: { output_id: string; text?: string; src?: string } }[];
applied_versions?: Record<string, number>;
};
/** 只有queued/running视为执行中;终态任务仍可能因has_more继续拉取剩余事件。 */
const active = (status: string) => status === "queued" || status === "running";
/** enabled仅控制画布AI模式显示;live才启用服务请求。tree通过ref保持最新,轮询effect按作用域重建。 */
export function useCanvasAI(workspace: string, root: string, live: boolean, tree: ReturnType<typeof useCanvasTree>) {
const [enabled, setEnabled] = useState(false);
const [runs, setRuns] = useState(new Map<string, AIRun>());
@@ -21,16 +29,22 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
const [activeEdges, setActiveEdges] = useState(new Set<string>());
const reference = useRef(tree); reference.current = tree;
const wake = useRef<() => void>(() => {});
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
const cache = useRef(new Map<string, AIRun>());
const edgeStates = useRef(new Map<string, Set<string>>());
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
const pendingRefresh = useRef(new Map<string, number>());
/** 发布新的Map/Set引用触发React更新;不把可变ref集合原样暴露成受控state。 */
const changed = () => { setRuns(new Map(cache.current)); setActiveEdges(new Set([...edgeStates.current.values()].flatMap((values) => [...values]))); };
// 单个effect内polling防并发轮询,dirty合并UI发布;AbortController统一中止这次作用域的读取。
useEffect(() => {
if (!live) return;
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
let polling = false, dirty = false, nextDiscovery = 0;
const controller = new AbortController();
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
async function discover() {
let cursor = ""; const seen = new Set<string>();
do {
@@ -39,7 +53,9 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
} while (cursor && alive);
}
/** 是否仍需较快跟踪,包含终态但事件尚未读完的任务。 */
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
/** 隐藏页面延后30秒;可见时串行发现、读事件、刷新已应用画布,再按活跃程度安排下一次。 */
async function poll() {
if (!alive || polling) return;
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
@@ -49,9 +65,11 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
for (const [id, previous] of cache.current) {
if (!active(previous.status) && !previous.has_more) continue;
let current = previous;
// 每页100事件,游标必须安全且不倒退,has_more时必须前进;每run最多保留最近1000条事件。
do {
const page = await request<Omit<AIRun, "board_id">>(canvasPath(workspace, previous.board_id) + `/ai/runs/${id}?after_seq=${current.next_seq}&limit=100`);
if (!Number.isSafeInteger(page.next_seq) || page.next_seq < current.next_seq || page.has_more && page.next_seq === current.next_seq) throw new Error("invalid_run_cursor");
// edge.active加入动画集合,completed/failed/skipped移除;任务终态清空剩余活动边。
const edges = edgeStates.current.get(id) || new Set<string>();
for (const event of page.events || []) {
if (event.logical_edge_id) { if (event.type === "edge.active") edges.add(event.logical_edge_id); else if (["edge.completed", "edge.failed", "edge.skipped"].includes(event.type)) edges.delete(event.logical_edge_id); }
@@ -63,6 +81,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
cache.current.set(id, current);
} while (current.has_more && alive);
}
// 等树不忙且不在本地事务中才刷新;版本冲突记录到conflicts,不直接用生成快照覆盖本地草稿。
for (const [board, version] of pendingRefresh.current) {
if (reference.current.busy || reference.current.transactionBusy) break;
const writer = reference.current.writers.current.get(board);
@@ -79,27 +98,34 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
polling = false;
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
}
/** 被唤醒时清计时器并强制下一次重新发现;已有poll运行时由其收尾继续调度。 */
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
wake.current = resume;
/** 页面重新可见时立即唤醒,避免继续等待后台长间隔。 */
const visible = () => { if (document.visibilityState === "visible") resume(); };
document.addEventListener("visibilitychange", visible);
void poll();
// effect清理中止GET、定时器和可见性监听,wake退为空实现;此处不清除缓存或自动取消服务端任务。
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
}, [workspace, root, live]);
/** 真实画布经tree.command启动任务,先插queued占位供UI显示,再唤醒跟踪;演示模式明确拒绝。 */
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
if (!live) throw new Error("demo_ai_unavailable");
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
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用于保持轮询,不表示服务端重新执行任务。 */
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: "{}" });
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
}
/** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
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);
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
}
// 对外只暴露显示状态和三个动作;调用者不直接修改cache、分页游标或刷新等待表。
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
}
@@ -1,17 +1,26 @@
/**
* 旧版单画布的自动保存Hook:短延迟备份本地草稿,较长延迟把最新快照交给CanvasSync。
* 它使用旧快照writer,不是新版冻结请求ledger;本地recovery记录也不是可靠保存文件字节的资源仓库。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { canvasFingerprint, canvasPath, CanvasSync, type CanvasSnapshot, type SyncState } from "@/lib/canvas-sync";
import { decodeCanvas, type CanvasDocument } from "@/lib/canvas";
/** snapshot存在才建立远端writer,无snapshot仍可本地备份;writer在挂载时初始化一次,调用方应按画布重挂载。
* key区分草稿作用域,paused阻断这里的新备份/入队,不在此取消已交给writer的请求。 */
export function useCanvasPersistence(document: CanvasDocument, workspace: string, board: string, key: string, snapshot: CanvasSnapshot | undefined, paused: boolean) {
const [sync, setSync] = useState<SyncState>({ status: "saved", version: snapshot?.version || 1 });
// alive限制writer通知更新已卸载组件;latest和pausedRef让异步回调读取当前文档/暂停值。
const alive = useRef(false);
const latest = useRef(document); latest.current = document;
const pausedRef = useRef(paused); pausedRef.current = paused;
// 同一挂载复用writer;状态变化时刷新sync并补本地备份,不随每次文档渲染新建同步器。
const [writer] = useState(() => snapshot ? new CanvasSync(api, canvasPath(workspace, board), snapshot, (next) => {
if (alive.current) { setSync(next); backup(); }
}) : null);
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
function backup() {
if (pausedRef.current) return;
try {
@@ -19,6 +28,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
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. */ }
}
// 文档变化重置250ms本地/800ms远端计时器,形成防抖;暂停不建立计时器,清理只取消尚未触发的任务。
useEffect(() => {
if (paused) return;
const local = setTimeout(() => {
@@ -28,13 +38,16 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
}, [document, writer, key, paused]);
// pagehide与卸载只执行同步本地backup,不保证远端请求在离页前完成;卸载后不再接收writer的UI通知。
useEffect(() => {
alive.current = true;
/** 页面隐藏/离开时尽量保存当前内存文档,暂停状态由backup内部再次检查。 */
const flush = () => backup();
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); backup(); alive.current = false; };
// Writer and key are stable for this board's mounted editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writer, key]);
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
}
+106
View File
@@ -1,3 +1,9 @@
/**
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
@@ -8,20 +14,34 @@ import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
/** 兼容多个旧命令端点的结果包络,可能返回父/子/多快照、事务身份、归档列表和版本;各使用路径再解码快照。 */
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
type Entry = LocalHistory | TransactionHistory;
/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。
* parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
// 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。
const migrationGate=useRef(!!migration);
const migrationFlush=useRef(false);
const [migrationHeld,setMigrationState]=useState(!!migration);
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
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');
@@ -29,33 +49,42 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
};
// documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。
const [documents, setDocuments] = useState(new Map([[root, initial]]));
const current = useRef(documents); current.current = documents;
const [status, setStatus] = useState("saved");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
// capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。
const memberCapability = useRef<Promise<boolean> | null>(null);
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
function protectedNodes(boardId: string) {
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
return ids;
}
const [pendingCommand, setPendingCommand] = useState(false);
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
// writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。
const writers = useRef(new Map<string, CanvasSync>());
const loading = useRef(new Set<string>());
const [retryLoad, setRetryLoad] = useState(0);
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
}
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
function resolveRecovery(boardId: string, restore: boolean) {
if(migrationBlocked())return;
const draft = pendingRecovery.current.get(boardId);
@@ -63,12 +92,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
}
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
const boardAliases = useRef(new Map<string, string>());
const [, tick] = useState(0);
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
const view = useRef(views); view.current = views;
/** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
function install(boardId: string, value: CanvasSnapshot) {
const writer = writers.current.get(boardId);
if (writer) writer.adopt(value);
@@ -80,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Initialization never writes a snapshot to the server.
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
const update = (boardId: string, action: CanvasAction) => {
if (migrationBlocked() || locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
const result = canvasReducer(history(before), action);
const protectedIDs = protectedNodes(boardId);
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
const prior = before.nodes.find((item) => item.id === node.id);
if (!prior || !protectedIDs.has(node.id)) return node;
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
// reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
publish(new Map(current.current).set(boardId, result.present));
};
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
/** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
/** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map<string, NodeChange<CanvasNode>[]>();
// 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
for (const change of changes) {
if (change.type === "add" || change.type === "replace") continue;
@@ -106,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
const next = { ...change, id: ref.id };
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
// ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。
if (next.type === "dimensions") {
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
@@ -116,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
};
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
const edgesChanged = (changes: EdgeChange[]) => {
const groups = new Map<string, EdgeChange[]>();
for (const change of changes) {
@@ -124,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
};
/** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */
const patchEdge = (id: string, data: EdgeData, transient = false) => {
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
};
/** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */
const select = (id: string) => {
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
};
/** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
function supportsMemberPositions(): Promise<boolean> {
if (!snapshot) return Promise.resolve(false);
if (!memberCapability.current) {
@@ -141,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
return memberCapability.current;
}
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
useEffect(() => {
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
}, [root, workspace, !!snapshot]);
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
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");
let document = current.current.get(boardId);
@@ -153,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
}
/** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */
async function exportDocuments() {
return exclusive(async () => {
await ensure(root, true);
const reachable = new Map<string, CanvasDocument>();
/** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
collect(root); return reachable;
});
}
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
useEffect(() => {
if (!snapshot || !parentBoardId) return;
let alive = true;
@@ -178,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Root identity is stable until this editor unmounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentBoardId, root, workspace]);
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
useEffect(() => {
for (const [boardId, document] of documents) for (const node of document.nodes) {
if (!view.current.boards.has(boardId)) continue;
@@ -192,6 +245,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Values and writers are scoped to this mounted root/workspace.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, retryLoad]);
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
useEffect(() => {
if (paused || migrationBlocked()) return;
const backup = setTimeout(() => {
@@ -208,7 +263,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
}, [documents, paused, workspace, memberJobs, migrationHeld]);
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
useEffect(() => {
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
const backup = () => {
if (pause.current || migrationBlocked()) return;
for (const [boardId, document] of current.current) {
@@ -219,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
window.addEventListener("pagehide", backup);
return () => { window.removeEventListener("pagehide", backup); backup(); };
}, [workspace]);
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
async function flush(deferMemberPositions = false) {
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
@@ -228,6 +287,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
async function prepareMigration(projectRoot:string){
if(!migrationGate.current)throw new Error('migration_gate_required');
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
@@ -240,12 +301,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for(const[boardId,document]of current.current){
const writer=writers.current.get(boardId);
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
// 本地另有不同恢复内容时转为用户选择,不能用当前内存直接覆盖它后迁移。
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
}
// Preserve the current view even if it closes while PATCH is in flight.
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
}
// 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。
migrationFlush.current=true;
try{await flush();}finally{migrationFlush.current=false;}
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
@@ -255,15 +318,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
}
}
/** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。
* 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */
async function exclusive<T>(operation: () => Promise<T>) {
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
locked.current = true; setBusy(true);
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
finally { locked.current = false; if (mounted.current) setBusy(false); }
}
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
}
/** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */
function accept(result: CanvasCommandResult) {
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()];
@@ -272,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
}
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
for (const id of result.archived_board_ids || []) {
const local=current.current.get(id);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
@@ -286,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
publish(next);
return [...new Set(values.map((value) => value.board_id))];
}
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
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");
return exclusive(async () => {
@@ -295,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await flush();
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
const execute = async (): Promise<CanvasCommandResult> => {
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 changed = accept(result);
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) {
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
}
// record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
return result;
} catch (error) {
const code = error instanceof Error ? error.message : "request_failed";
// 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
else { retryOperation.current = null; setPendingCommand(false); }
throw error;
@@ -320,12 +395,15 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
async function runMembers() {
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
if (job.refresh) {
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
const next = new Map(current.current);
@@ -336,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, remote] of refreshed) install(id, remote);
publish(next); job.refresh = false;
}
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
const atomic = await supportsMemberPositions();
await flush(atomic === true);
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
@@ -344,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
return [id, node.position];
}));
// 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
const key = crypto.randomUUID(), historyIndex = undo.current.length;
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
job.run = async () => {
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
/** 移动结果已含新坐标,合并前只把源节点暂存位置还原到基线,避免重复施加坐标变化。 */
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
/** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
// 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
const next = new Map(current.current);
for (const [id, value] of values) {
@@ -369,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, value] of values) install(id, value);
publish(next);
undo.current = rebasedHistory;
// 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
};
}
@@ -378,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
job.error = error instanceof Error ? error.message : "request_failed";
// A definite rejection performed zero writes. A retry may prepare fresh
// versions; ambiguous transport failures must retain the exact body/key.
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
showMembers();
} finally {
@@ -385,24 +471,31 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (members.current.length && !members.current[0].error) void runMembers();
}
}
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
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"));
return new Promise<void>((resolve) => {
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
/** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。
* 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
if(migrationBlocked())return;
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
if (node.pendingUpload !== id) return node;
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
const {pendingUpload,...ready}=node;
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
}) });
/** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
function movePendingImage(source: string, target: string, id: string) {
if(migrationBlocked())throw new Error("canvas_busy");
const from = current.current.get(source)!, to = current.current.get(target)!;
@@ -413,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
}
/** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */
async function refresh(boardId: string) {
return exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) return;
@@ -423,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
});
}
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
async function prepareApply(boardId: string, nodeId: string) {
await exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
@@ -436,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
setError("");
});
}
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
async function undoAction(isRedo = false) {
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
/** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
/** 重做本地快照时更新子图board引用,其它节点内容保留。 */
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
// 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。
if (!("id" in entry) && entry.pendingMove) {
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
@@ -452,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
stack.pop();destination.push(entry);tick((v)=>v+1);return;
}
}
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
if ("id" in entry) {
if (isRedo) {
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
@@ -464,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
stack.pop(); destination.push(entry);
}
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
} else {
const before = current.current.get(entry.boardId); if (!before) return;
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
@@ -472,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
tick((v) => v + 1);
}
/** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
// 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。
// transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
@@ -1,28 +1,42 @@
/**
* 旧编辑器的内存上传任务管理。原文件、Blob预览URL和AbortController随Hook挂载存活,资源上传成功后由commit绑定到本地节点。
* 这是可取消的页面内流程,没有持久文件仓库;卸载会中止上传并清理URL,不能承诺跨刷新继续上传。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api, errorText } from "@/lib/api";
import { uploadCanvasImage, type CanvasAsset } from "@/lib/canvas-assets";
import type { CanvasDocument, CanvasNode } from "@/lib/canvas";
/** draft指定节点归属与可选关联边,调用方负责先创建可编辑占位并正确处理提交时归属。 */
export type ImageUploadDraft = { owner: string; node: CanvasNode; edge?: CanvasDocument["edges"][number] };
/** file保存原字节、localURL用于即时预览、key用于同任务重试幂等;ready可等待本地图片尺寸解码。 */
export type ImageUploadJob = ImageUploadDraft & { file: File; localURL: string; key: string; status: "uploading" | "failed" | "uploaded"; error?: string; controller: AbortController; ready?: Promise<void>; asset?: CanvasAsset };
/** Uploads attach file references to existing editable local nodes; transient URLs never enter autosaves. */
/** commit通过ref读取最新回调;pending按节点ID存任务,jobs数组只作React显示发布。 */
export function useImageUploads(workspace: string, commit: (draft: ImageUploadDraft, asset: CanvasAsset) => void) {
const pending = useRef(new Map<string, ImageUploadJob>());
const [jobs, setJobs] = useState<ImageUploadJob[]>([]);
const mounted = useRef(true), onCommit = useRef(commit); onCommit.current = commit;
/** 挂载期间发布新的任务数组;任务对象仍是同一引用,字段更新后需调用changed触发显示。 */
const changed = () => { if (mounted.current) setJobs([...pending.current.values()]); };
/** 中止任务、释放预览URL并移出Map;并不在这里删除文档节点或服务端资源。 */
function remove(id: string) { const job = pending.current.get(id); if (!job) return; job.controller.abort(); URL.revokeObjectURL(job.localURL); pending.current.delete(id); changed(); }
// 卸载统一abort并回收URL,清Map防止迟到上传结果提交;StrictMode重新挂载effect时复位mounted。
useEffect(() => { mounted.current = true; return () => { mounted.current = false; for (const job of pending.current.values()) { job.controller.abort(); URL.revokeObjectURL(job.localURL); } pending.current.clear(); }; }, []);
/** uploading状态防并发重试;开始新尝试前换controller并清错误,保留原幂等key。
* 上传与ready并行,二者都成功后才commit;commit抛错也会进入failed状态,允许再次处理。 */
async function run(job: ImageUploadJob) {
if (job.status === "uploading") return;
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
try {
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的新任务。
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
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(); } }
}
/** 先创建Blob URL并让调用方建立草稿,草稿构造失败立即释放URL;任务初始failed以便run统一转uploading。 */
function start(file: File, draft: (localURL: string) => ImageUploadDraft) {
const localURL = URL.createObjectURL(file);
let value: ImageUploadDraft;
@@ -30,18 +44,23 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
pending.current.set(job.node.id, job); changed();
// Browser decoding runs locally, concurrently with the original-byte upload.
// 纯图片并行解码自然尺寸,限制初始长宽不超过480(不主动放大),再分别设置至少24px的显示尺寸。
if (job.node.data.kind === "plain-image") {
job.ready = new Promise<void>((resolve,reject) => {
const image = new window.Image(); image.onload = () => {
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
const scale = Math.min(1,480/image.naturalWidth,480/image.naturalHeight);
const size = { width: Math.max(24,Math.round(image.naturalWidth*scale)), height: Math.max(24,Math.round(image.naturalHeight*scale)) };
job.node = { ...job.node, ...size, style: { ...job.node.style, ...size } }; changed(); resolve();
}; image.onerror = () => reject(new Error("无法读取图片尺寸。")); image.src = localURL;
});
}
// start同步返回任务对象,异步上传由run内部捕获失败,不要求调用方等待才显示占位。
void run(job);
return job;
}
// hasPending包含失败但不包含uploaded,active只看uploading;loaded仅在正式图片加载后移除已上传预览。
// 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); } };
}
+6
View File
@@ -1,3 +1,9 @@
/**
* 按既有静态资源命名约定展示仪表盘图标的小封装,不负责画布图标库的分片加载或搜索。
*/
/** name是调用方提供的资源后缀,拼成/assets/dashboardcontext-imgIcon{name}.svg。
* size同时设置宽高,默认18px;空alt表示装饰用途,承载动作的外层控件须另有可访问名称。
* 此处没有资源白名单校验或加载失败占位,调用方应使用项目已有的图标名。 */
export function Icon({ name, size = 18 }: { name: string; size?: number }) {
return (
<img
+14
View File
@@ -1,3 +1,7 @@
/**
* 公开落地页的客户端展示组件。包含品牌导航、可点击的创作示意图、功能说明、工作方式、FAQ与登录/演示入口。
* 示意卡片只改变本页高亮,不创建真实画布元素;本模块不读取账号、不调用编辑器,也不持久化状态。
*/
"use client";
import { useState } from "react";
@@ -6,7 +10,9 @@ import { ArrowUpRight, ArrowRight, ChevronDown, FileText, Image as ImageIcon, La
import { Brand } from "@/components/brand";
import styles from "./landing-page.module.css";
/** 三个示意卡片对应的页脚说明;数组索引与active及预览按钮一一对应。 */
const cards = ["先留住一个念头", "让不同的线索相遇", "慢慢成为你的作品"];
/** FAQ静态问答数据;这里只呈现文案,访问权限与演示存储的实际执行在对应业务模块中。 */
const questions = [
["还没想好要做什么,也可以开始吗?", "当然。一个词、一张参考图、一段还不成形的想法,都可以成为起点。先放下来,再慢慢找到它们之间的联系。"],
["我可以放些什么在画布里?", "文字、笔记、图片、脚本和图标。你可以自由移动、调整样式、用线连接,也可以用分区把相关内容放在一起。"],
@@ -14,9 +20,17 @@ const questions = [
["可以先体验,再注册吗?", "可以。点击「先逛逛工作台」进入本地演示。演示内容保留在当前浏览器,登录后可以建立自己的工作空间。"],
];
/** 本页只有两个交互状态:预览卡片单选,以及最多展开一个问题的FAQ。
* 导航锚点定位本页三个section;登录入口进入/login,本地演示入口携带demo=1进入工作台。
* 主体的SVG连线、花朵、光标和功能插图是展示图形,不接入画布命中、拖动或同步链路。 */
export default function LandingPage() {
// 初始选中第一张预览卡;三张卡片与底部三个圆点均直接设置同一个索引,aria-pressed同步表达选择。
const [active, setActive] = useState(0);
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
const [faq, setFaq] = useState<number | null>(null);
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
return <div className="landing"><div className={styles.page}>
<header className={styles.nav}>
<Brand />
+8
View File
@@ -1,11 +1,19 @@
/**
* 应用级反馈设施:提供共享Tooltip时间参数与通知挂载点。
* 这个模块只负责提示的呈现环境,任务进度、错误事实和通知触发仍由业务调用方管理。
*/
"use client";
import { createContext, type ReactNode } from "react";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Toaster } from "sonner";
/** 默认false使独立挂载的IconButton退回原生title;只有AppFeedback子树启用统一浮层提示。 */
export const FeedbackContext = createContext(false);
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
export function AppFeedback({ children }: { children: ReactNode }) {
return <FeedbackContext.Provider value={true}>
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
+11
View File
@@ -1,9 +1,15 @@
/**
* 通用按钮的样式与属性适配层。variant表达视觉语义,size控制尺寸;点击、禁用和表单行为由调用方传入。
* 可通过asChild复用子元素,避免链接外再套按钮;这里不保存业务状态,也不默认指定button的type。
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/** 将基础交互样式、语义变体和尺寸变体组合成className;未指定时使用default/default。
* focus-visible提供键盘焦点反馈,disabled类限制指针交互并淡化外观,内嵌SVG统一尺寸。 */
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
@@ -34,6 +40,7 @@ const buttonVariants = cva(
},
);
/** 在原生button属性上增加样式变体和asChild;ref仍按按钮声明,实际宿主由asChild决定。 */
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -41,8 +48,11 @@ export interface ButtonProps
asChild?: boolean;
}
/** 转发ref和剩余原生属性,供调用方设置事件、可访问名称、disabled和表单属性。
* Slot把属性合并到唯一子元素;使用链接等宿主时,其语义及行为由该子元素承担。 */
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
},
);
// forwardRef生成包装组件后补显示名,便于React开发工具定位。
Button.displayName = "Button";
export { Button, buttonVariants };
+16
View File
@@ -1,3 +1,7 @@
/**
* 基于Radix的对话框展示封装:保留其开关、焦点管理和可访问语义,补项目统一的遮罩、布局与动画。
* 调用方通过Root的open/onOpenChange控制业务流程;保存中是否允许关闭等规则不由本模块决定。
*/
"use client";
import * as React from "react";
@@ -6,14 +10,19 @@ import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/** Root提供受控或非受控开关上下文,本层不再复制一份open状态。 */
const Dialog = DialogPrimitive.Root;
/** Trigger接入同一Root,负责触发打开;可沿用Radix的asChild组合调用方控件。 */
const DialogTrigger = DialogPrimitive.Trigger;
/** Portal把浮层移出页面普通布局,避免受父容器裁切;挂载方式沿用Radix。 */
const DialogPortal = DialogPrimitive.Portal;
/** 暴露关闭原语,供业务在底部动作区等位置放额外关闭入口。 */
const DialogClose = DialogPrimitive.Close;
/** 转发遮罩ref与属性,铺满视口;开关动画由Radix的data-state驱动,className允许调用方补样式。 */
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -29,6 +38,9 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
@@ -53,6 +65,7 @@ const DialogContent = React.forwardRef<
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
/** 标题区只组织纵向间距与响应式文字对齐;原生div属性和children原样透传。 */
const DialogHeader = ({
className,
...props
@@ -67,6 +80,7 @@ const DialogHeader = ({
);
DialogHeader.displayName = "DialogHeader";
/** 动作区在窄屏使用反向纵排,在sm及以上改为靠右横排;不绑定提交或取消动作。 */
const DialogFooter = ({
className,
...props
@@ -81,6 +95,7 @@ const DialogFooter = ({
);
DialogFooter.displayName = "DialogFooter";
/** 转发Radix标题的ref/属性并统一字号;标题仍参与对话框的可访问命名关联。 */
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -96,6 +111,7 @@ const DialogTitle = React.forwardRef<
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
/** 转发Radix说明文字的ref/属性并使用弱化文字样式;说明关联由Radix上下文维护。 */
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+9
View File
@@ -1,3 +1,7 @@
/**
* 画布等工具栏使用的图标按钮。统一按钮尺寸、图标与可访问名称,并按反馈Provider决定使用浮层提示还是原生title。
* 按钮的业务动作由onClick等原生属性传入;这里不维护工具选择状态。
*/
"use client";
import { FeedbackContext } from "./app-feedback";
@@ -6,13 +10,17 @@ import { useContext, forwardRef, type ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
import styles from "./icon-button.module.css";
/** icon提供Lucide图标实现,label提供默认可访问名称与提示;不接受任意children替换内部图标。 */
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
icon: LucideIcon;
label: string;
};
/** 将ref交给真实button;type默认为button,避免放进表单时意外提交。
* 调用方仍可传aria-pressed、disabled等状态,组件不会自行判断命令是否可用。 */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
const feedback = useContext(FeedbackContext);
const button = (
<button
@@ -26,6 +34,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
</button>
);
// 没有全局提示环境就返回原生按钮;空title也不创建Tooltip。提示通过Portal避免被工具栏裁切。
if (!feedback || !title) return button;
return <Tooltip.Root><Tooltip.Trigger asChild>{button}</Tooltip.Trigger><Tooltip.Portal>
<Tooltip.Content className={styles.tooltip} sideOffset={7} collisionPadding={8}>
@@ -1,3 +1,7 @@
/**
* 账户资料编辑与新建工作区共用的客户端对话框。保存后通过回调更新宿主目录/资料,再关闭;
* demo分支只构造本地结果,真实分支走profile PATCH或workspaces POST,不由组件直接操作canvas runtime。
*/
"use client";
import { useState, type FormEvent } from "react";
@@ -7,6 +11,7 @@ import type { Workspace } from "@/lib/workspace";
import { UserAvatar } from "./user-avatar";
import styles from "./workspace.module.css";
/** mode决定表单,busy控制当前提交和退出,error用于就地反馈;输入默认值来自打开时profile。 */
export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorkspace }: {
mode: "settings" | "workspace";
profile: Profile;
@@ -20,6 +25,8 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
const settings = mode === "settings";
const name = profile.display_name || profile.username;
/** 阻止原生导航,捕获FormData后提交;成功先通知宿主再onClose,失败显示错误并恢复按钮。
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
@@ -27,6 +34,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
setBusy(true);
setError("");
try {
// 设置模式只提交display_name/username,避免将整个profile(含只读信息)作为修改payload。
if (settings) {
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) });
@@ -41,6 +49,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
finally { setBusy(false); }
}
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
return (
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
<DialogContent className={styles.settingsPanel}>
@@ -1,20 +1,28 @@
/**
* 侧栏与画布页面共享的当前board元数据Context。只保存ID、工作区、路由引用与标题,
* 不保存元素、加载任务或ProjectEngine;目录改名可更新标题而无需重建业务画布。
*/
"use client";
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { Board } from "@/lib/workspace";
/** null代表当前没有已解析画布,workspace_ref保留路径slug/引用与真实workspace_id的对应关系。 */
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
/** 暴露React状态setter,调用方可条件更新以免迟到改名覆盖另一个活动board。 */
const ActiveBoardContext = createContext<{
board: ActiveBoard;
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
} | null>(null);
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
const [board, setBoard] = useState<ActiveBoard>(null);
const value = useMemo(() => ({ board, setBoard }), [board]);
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
}
/** 缺少Provider时拒绝读取;不从浏览器路径凭空构造一条已认证board记录。 */
export function useActiveBoard() {
const context = useContext(ActiveBoardContext);
if (!context) throw new Error("useActiveBoard requires ActiveBoardProvider");
@@ -1,3 +1,7 @@
/**
* 侧栏画布树显示组件,使用业务目录库派生结构/搜索路径,只保存哪些目录项被用户折叠。
* 不重写board父子归属;活动路径和搜索可临时展开祖先,路由Link负责进入真实画布页面。
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
@@ -6,13 +10,16 @@ import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardB
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
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 branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
useEffect(() => {
const path = activeBoardAncestors(boards, active);
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
}, [active, boards]);
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
const expanded = !!query.trim() || !closed.has(board.id);
@@ -22,5 +29,6 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
})}</ul>;
}
// 有结果显示树;仅搜索无命中给提示,原始目录空时不伪造一条默认页面。
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
}
@@ -1,3 +1,8 @@
/**
* 应用级Canvas运行时装配点。一次安装模型registry与React renderer目录,按账户/工作区创建runtime,
* 按项目root安装Foundry服务适配。组件只装配依赖,业务根、history、ledger和持久化仍由下层实例管理。
* 真实宿主卸载才关闭项目;单个画布页面切换只借还视图,不在这里每次重建engine。
*/
"use client";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
@@ -10,20 +15,29 @@ import { createRuntimeHost, createWorkspaceRuntime, type RuntimeHost } from "@/l
import { createBrowserDigests, createBrowserImageDecoder, createBrowserStorage } from "@/lib/canvas-runtime/browser";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns,createFoundryModelLoader } from "@/lib/canvas-service/index";
/** 应用共享RuntimeHost引用,未装配时为null;renderer Context另有内置显示目录默认值。 */
const Context = createContext<RuntimeHost | null>(null);
/** 模型定义与显示实现一起在宿主初始化捕获,子页面用此目录保持一致解释。 */
const Renderers=createContext(defaultCanvasRenderers);
/** 读取宿主安装的React renderer集合,不为每个节点重新构造类型表。 */
export function useCanvasRenderers(){return useContext(Renderers);}
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
const [{host,renderers}] = useState(() => {
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
const host=createRuntimeHost({ registry,
/** 每身份作用域绑定transport,current护栏由host提供,流读取也要防换账户后的迟到结果。 */
modelLoader(accountId,workspaceId,_authEpoch,current){return createFoundryModelLoader({workspaceId,registry,transport:createFoundryTransport({accountId,isCurrentIdentity:current})});},
/** 工作区持有浏览器存储、摘要/解码池;root项目共享其身份范围,各自再装配业务端口。 */
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
/** 一项目一组协议适配器;summary策略只压缩已协调回执,不改变网络请求或原始操作身份。 */
adapters(rootId, current) {
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
return { runs: createFoundryRuns({ workspaceId, rootId, registry, transport }), persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport, resolvedReceiptPolicy:'summary' }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
},
// 同工作区错误用稳定通知id更新,不创建另一份任务状态;错误恢复仍须runtime明确操作。
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
});
},
@@ -31,7 +45,10 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
});
return {host,renderers};
});
// effect重放的代次令牌只保护宿主清理,不是账户authEpoch,也不进入持久模型。
const lifetime = useRef(0);
/** cleanup延到微任务检查代次:Strict Mode同步重设effect会递增代次,旧cleanup不关闭新挂载宿主。
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
useEffect(() => {
const generation = ++lifetime.current;
// Strict Mode replays effects synchronously; only a real detach retires the host.
@@ -39,6 +56,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
}, [host]);
return <Context.Provider value={host}><Renderers.Provider value={renderers}>{children}</Renderers.Provider></Context.Provider>;
}
/** 需要runtime的页面必须在根Provider内,缺失装配明确抛错而不偷建一个临时host。 */
export function useCanvasRuntimeHost() {
const host = useContext(Context);
if (!host) throw new Error("CanvasRuntimeHost is missing");
@@ -1,8 +1,13 @@
/**
* 工作台标题的局部交互状态机:idle→selected→editing→saving,支持二次点击/F2编辑、Enter/失焦保存、Escape取消。
* 只管理草稿、焦点和提示,真正改名由异步onSave负责;组件本身不修改目录或画布文档。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./editable-title.module.css";
/** editable控制能否进入编辑;title是宿主已采用值,draft只在一次编辑会话内使用,保存失败回editing重试。 */
export function EditableTitle({ title, editable, onSave, className = "" }: {
title: string;
editable: boolean;
@@ -15,11 +20,14 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
const shell = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const button = useRef<HTMLButtonElement>(null);
// ref防Enter和随后的blur重复调用;cancelled防Escape/已完成后的blur重新提交。
const pending = useRef(false);
const cancelled = useRef(false);
/** selected阶段才监听外侧pointerdown;离开此模式即退订,编辑/保存不由这条外侧点击逻辑擅自退出。 */
useEffect(() => {
if (mode !== "selected") return;
/** 只在实际DOM目标落在shell外时取消选择,标题内部点击保留进入编辑的机会。 */
const deselect = (event: PointerEvent) => {
if (event.target instanceof Node && !shell.current?.contains(event.target)) setMode("idle");
};
@@ -27,10 +35,12 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
return () => document.removeEventListener("pointerdown", deselect);
}, [mode]);
/** 进入editing后挂载的input才可聚焦/全选,不在渲染阶段直接访问未就绪DOM。 */
useEffect(() => {
if (mode === "editing") { input.current?.focus(); input.current?.select(); }
}, [mode]);
/** 每次从宿主最新title初始化草稿并清旧错误,不能沿用上次取消的输入。 */
function edit() {
if (!editable) return;
cancelled.current = false;
@@ -38,12 +48,15 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setError("");
setMode("editing");
}
/** 标记取消以抑制blur保存,回selected并下一帧把焦点还给标题按钮。 */
function cancel() {
cancelled.current = true;
setError("");
setMode("selected");
requestAnimationFrame(() => button.current?.focus());
}
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
* 成功回idle,失败留草稿/错误重编辑,finally释放pending允许下一次明确尝试。 */
async function save() {
if (pending.current || cancelled.current) return;
const name = draft.trim();
@@ -65,6 +78,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setMode("editing");
} finally { pending.current = false; }
}
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
return (
<div ref={shell} className={`${styles.shell} ${className}`}>
{mode === "editing" || mode === "saving" ? (
+28
View File
@@ -1,3 +1,7 @@
/**
* 画布路由的客户端生命周期桥。把路径引用解析成board/profile,再选择新版engine、渐进预览或旧snapshot编辑器。
* 页面只持显示与导航状态,项目由RuntimeHost/WorkspaceRuntime拥有;卸载中断本页订阅,但已开始的项目加载可转后台完成。
*/
"use client";
import { useEffect, useRef, useState } from "react";
@@ -16,11 +20,16 @@ import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import dynamic from "next/dynamic";
/** 旧编辑器只在浏览器加载,避免服务端执行依赖DOM的画布实现。 */
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
/** 新版编辑器同样独立客户端bundle,模型选择由loadCanvasEntry结果决定,而非界面手动猜格式。 */
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
/** 只读渐进预览先出现,已知内容足够后可升级为partial engine,保持原视口。 */
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
/** route将结果绑定到这次路径;engine/snapshot/migration分别来自实际选择的协议路径。 */
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
/** 当前页面只借用host与renderer,retry触发重新读取;不在页面state复制业务文档或history。 */
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const renderers=useCanvasRenderers();
const demo = useSearchParams().get("demo") === "1";
@@ -30,12 +39,15 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
// 预览视口暂存于ref,切成可编辑视图时交作initialViewport;新路由重置,不当文档编辑保存。
const previewViewport=useRef<Viewport|undefined>(undefined);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const navigationGeneration=useRef(0);
/** 每条路由/重试新建读取生命周期,清旧显示;active、AbortController和host身份护栏共同隔离迟到结果。 */
useEffect(() => {
setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
// 演示目录走旧本地画布,不发canonical读取、不绑定真实账户项目。
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -44,46 +56,62 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}
let active = true;
const controller = new AbortController();
// 保存开始时身份,并在profile解析后取得currentIdentity闭包,后续await返回均据此判断是否还能采用结果。
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
// 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;
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
const completeInBackground=()=>{if(partialEngine?.loading.getState()&&currentIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
/** 串行采用最新预览,先openPartial再发布UI并保存;期间新预览覆盖latest等待槽而非并行改根。 */
function pump(){
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&&currentIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
}
// 导航代次递增传入共享loader,查询身份与取消订阅由host/loader维护;入口优先canonical防旧writer误开。
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
/** 只有本board已在前缀中才显示;有元素后推动partial engine,尚未建立engine时用只读预览。 */
onPreview:(value,board,profile)=>{if(active&&currentIdentity?.()&&value.document.canvases.has(board.id)){if(!partialEngine)setPreview({board,value,route});if(value.document.elements.size){latest={value,board,accountId:profile.id};void pump().catch(reportBackground);}}},
/** profile解析后绑定账户并读取模型;若开始后的authEpoch已变且当前账户不同于响应账户,拒绝旧导航认领。 */
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
}).then(async result => {
if (!active||currentIdentity&&!currentIdentity()) return;
// 迁移完成回调只递增retry重开入口,不在旧组件内手工切换文档格式。
if (result.mode === 'legacy') {
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&&currentIdentity?.())setRetry(value=>value+1);}};
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
}
// 服务已返回新模型但本地仍有同根pending时,查询并收尾原迁移记录,不生成新迁移请求。
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
catch(error){reportBackground(error);}
}
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
while(pumping)await pumping;
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});
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
})
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
// 清掉未采用预览并中断本页读取;已拥有的partial项目交后台,绝不在这里直接engine.close。
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
const current = loaded?.route === route ? loaded : null;
const currentPreview=preview?.route===route?preview:null;
const board = current?.board??currentPreview?.board;
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */
useEffect(() => {
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, workspaceId, setActiveBoard]);
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
// 显示优先级:engine→只读预览→旧snapshot→读取占位;重试错误提示与已显示前缀可并存。
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&currentPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
+5
View File
@@ -1,8 +1,13 @@
/**
* 头像的轻量显示降级:指定URL加载失败时用名字首段的大写表示,新的URL可重新尝试。
* 失败标记仅属当前组件,不写用户资料,也不把一次图片失败当成账户失效。
*/
"use client";
import { useState } from "react";
import styles from "./workspace.module.css";
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
export function UserAvatar({ name, url, large = false }: {
name: string;
url?: string | null;
+20
View File
@@ -1,3 +1,9 @@
/**
* 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError,
* 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。
* 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。
*/
/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */
export type Profile = {
id: string;
username: string;
@@ -5,20 +11,25 @@ export type Profile = {
email: string | null;
avatar_url?: string | null;
};
/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */
export class ApiError extends Error {
readonly status: number;
readonly details?: unknown;
readonly retryAfter?: string | null;
/** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
}
}
/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。
* 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch("/api/foundry/" + path, {
...options,
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
let body: unknown;
@@ -26,21 +37,27 @@ export async function api<T>(
else {
try { body = await res.json(); }
catch (error) {
// 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。
if (options.signal?.aborted) throw options.signal.reason;
const malformed = error instanceof SyntaxError;
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
}
}
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
// 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。
if (!res.ok) {
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"));
}
return body as T;
}
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage,
* 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */
export async function startOAuth(provider: "google" | "slack") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -53,11 +70,14 @@ export async function startOAuth(provider: "google" | "slack") {
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。
sessionStorage.setItem("foundry_verifier", verifier);
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
}
/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
// 文案只解释既有领域状态;“保留/暂停”等实际行为须由触发错误的模块保证,不能由映射表证明。
const messages: Record<string, string> = {
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
incomplete_model_stream: "画布数据未加载完整,请重新加载。",
+12
View File
@@ -1,7 +1,14 @@
/**
* 工作区画布目录的读取与展示树派生。完整分页包含子分区,随后用parent_board_id构建侧栏关系。
* 显示树可保守容纳孤儿/循环记录,不据此修改服务端owner,也不替代文档模型中的canvas树校验。
*/
import { matchesRef, type Board } from "./workspace.ts";
/** 页面注入的通用JSON请求能力,传入signal供导航/卸载取消。 */
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** Include children and finish pagination before building ownership paths. */
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
const boards = new Map<string, Board>();
let offset = 0;
@@ -16,7 +23,10 @@ export async function loadBoardDirectory(request: Request, workspace: string, si
offset = page.next_offset;
}
}
/** 侧栏派生分支:保留原board记录引用,children是本次展示树容器。 */
export type BoardBranch = { board: Board; children: BoardBranch[] };
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
export function boardDirectory(boards: Board[]): BoardBranch[] {
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
const roots: BoardBranch[] = [];
@@ -33,12 +43,14 @@ export function boardDirectory(boards: Board[]): BoardBranch[] {
}
return roots;
}
/** 按ID或slug找到当前项,包含自身向上收集祖先ID,重复ID即停,供侧栏自动展开。 */
export function activeBoardAncestors(boards: Board[], ref?: string) {
const result = new Set<string>();
let item = boards.find((board) => matchesRef(board, ref));
while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); }
return result;
}
/** 忽略首尾空白并做本地小写匹配;父标题命中保留整子树,否则保留命中后代及其路径祖先。 */
export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] {
const term = query.trim().toLocaleLowerCase();
if (!term) return branches;
+17
View File
@@ -1,30 +1,45 @@
/**
* 迁移协调器的浏览器装配层。旧编辑视图持共享Web Lock,迁移操作取同一键的独占锁,
* 配合本地pending记录阻止已发出迁移却结果未知时继续旧格式写入。锁协调本浏览器,服务端版本/幂等校验另行负责。
*/
import type {Registry} from './canvas-core/types.ts';
import {fault} from './canvas-core/values.ts';
import {createFoundryTransport} from './canvas-service/foundry.ts';
import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts';
/** 页面提供身份、可迁移根和完成后的重开回调;rootId缺失不代表可任意推测迁移范围。 */
export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
function ports(options:BrowserScope){
const locks=options.locks??globalThis.navigator?.locks;
if(!locks)throw fault('migration_lock_unavailable');
return{locks,storage:options.storage??globalThis.localStorage};
}
/** 获取锁前后均检查身份/取消,旧页面不能借已等待的锁继续对新账户操作。 */
const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();};
/** Held for the entire lifetime of an editable legacy view, not just each PATCH. */
/** 为旧编辑视图整个生命周期借共享锁,release返回Promise确认锁请求已结束。
* ifAvailable不排长队;迁移占用或本canvas有未确认迁移时立即拒绝开放旧编辑。 */
export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){
check(options);const{locks,storage}=ports(options);
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
try{
check(options);if(!lock)throw fault('migration_busy');
if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown');
check(options);
// 回调等待released以保持共享锁;不能在第一次PATCH完成后就把编辑门解除。
let unlock!:()=>void;const released=new Promise<void>(yes=>{unlock=yes;});
/** 解开锁回调并等待平台释放;resolve可重复调用,已释放后的再次release仍等同一请求结束。 */
const release=async()=>{unlock();await request;};
/** 页面生命周期中止时释放锁,异步清理异常不制造未处理拒绝。 */
const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true});
resolve({release});
try{await released;}finally{options.signal.removeEventListener('abort',abort);}
@@ -33,11 +48,13 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
void request.catch(reject);return ready;
}
/** 连接localStorage、专属账户transport和UUID生成器;真正迁移在同一工作区独占锁内执行。 */
export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){
check(options);const{locks,storage}=ports(options);
return createCanvasMigration({scope:options.scope,registry:options.registry,storage,
transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}),
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
check(options);if(!lock)throw fault('migration_busy');return work();
}),
+11
View File
@@ -1,15 +1,26 @@
/**
* 旧画布的图片上传辅助,与新版canvas-service/assets的冻结资源协议分开保留。
* 同一次调用的重试沿用FormData和幂等键;这里只返回旧版素材记录,不维护可恢复上传账本。
*/
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
export const ASSET_LIMIT = 20 * 1024 * 1024;
/** 仅网络TypeError或5xx且未取消时追加一次同键重试;其它错误直接交给调用方。
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
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");
const form = new FormData(); form.append("image", file, name);
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
let asset: CanvasAsset;
try { asset = await send(); } catch (error) {
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
asset = await send(); // The same bytes/key retry a possibly committed upload.
}
// 成功HTTP不等于合法素材,继续核对ID、规范源站/路径及正有限尺寸,不接受任意远端URL。
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
let url: URL;
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
+18
View File
@@ -1,14 +1,27 @@
/**
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
*/
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
type Point={x:number;y:number};
/** Compatibility renderer data only; never the authoritative element schema. */
/** 兼容卡片显示的 AI 配置,不是已受理生成任务或冻结请求。 */
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
/** 兼容分区端口布局数据,连接到内部 node/handle 的关系由业务模型解释。 */
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
/** 展开/折叠时的显示尺寸与内容比例,投影时用于分区展示。 */
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
/** 内置旧卡片的类型枚举,不限制 registry 可扩展的 componentId 集合。 */
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
/** 分区标题的隐藏/固定大小/随缩放显示模式。 */
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
/** 卡片正文的兼容排版配置,不含编辑器实例或 DOM 状态。 */
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
/** 显示视图与真实 owner 分开记录;id+incarnation 供命令拒绝旧代次目标。 */
export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string};
/** 节点投影输入:正文/样式/资源及分区入口,不是另一份可直接写入的文档记录。 */
export type CanvasNodeData = {
__headlessTarget?:RenderedTarget;
kind: ElementKind;
@@ -29,9 +42,13 @@ export type CanvasNodeData = {
viewScale?: number;
};
/** 旧边绘制器支持的端点标记形状。 */
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
/** 箭头/符号尺寸与填充;不等于连线的真实端口身份。 */
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
/** 贝塞尔链控制结点,包含两侧切线与编辑约束模式。 */
export type BezierKnot = { id: string; position: Point; in: Point; out: Point; mode: "corner" | "smooth" | "symmetric" };
/** 旧边外观、动画、已投影路径和执行样式;实际生成/执行事实由 runtime 持有。 */
export type EdgeData = {
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
@@ -40,4 +57,5 @@ export type EdgeData = {
};
/** Geometry input consumed by the compatibility layout, independent of UI nodes. */
/** 仅供兼容锚点/布局计算的节点几何,measured 优先级由具体布局函数决定。 */
export type CanvasGeometryNode={position:Point;width?:number;height?:number;style?:{width?:unknown;height?:unknown};measured?:{width?:number;height?:number};data:{kind:string;rotation?:number;fontSize?:number;viewScale?:number}};
@@ -1,3 +1,7 @@
/**
* 连线超出原生端点包围范围的额外绘制索引。用曲线控制点凸包加半线宽保守包住笔画,
* 不要求求出三次曲线的精确极值。无法获取锚点/协议或耗尽预算时返回 unknown,宿主应保守保留显示。
*/
import type {Registry} from '../canvas-core/types.ts';
import type {projectCanvas} from './projection.ts';
import type {HitServices} from './hit-query.ts';
@@ -6,39 +10,57 @@ import {equal} from '../canvas-core/values.ts';
import {compatConnector} from './connectors.ts';
/** Conservative cubic control hull + half stroke, in the displayed scene space.
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
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');
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
// Only inspected edges need node entries. Preserve last-ID-wins behavior without
// allocating an entry and pair array for every unrelated projected node.
// 仅为边预算内真正要检查的端点建节点表;仍遍历节点,但不为所有无关节点分配 Map 条目。
for(let i=0;i<Math.min(maxEdges,projection.edges.length);i++){const edge=projection.edges[i];endpoints.add(edge.source);endpoints.add(edge.target);}
if(endpoints.size)for(const node of projection.nodes)if(endpoints.has(node.id))nodes.set(node.id,node);
let remainingSegments=maxSegments,computed=0;
for(let index=0;index<projection.edges.length;index++){
// 超出边预算给全局 paint_budget,而非声称未检查的边没有额外范围。
if(index>=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index];
try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error();
/** 优先用 renderer 锚点;原生 record-v1 不能猜旧卡片把手,缺测量即归 unknown。 */
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 protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。
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);
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。
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++;
// 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);
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 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;
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);}}
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 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 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});
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
}catch{cache?.delete(edge.id);unknown.push(edge.id);}
}
return{regions,unknown,computed};
}
/** 无缓存的单次计算入口,供调用方独立获取额外范围及未知原因。 */
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. */
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
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;
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
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;}
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)});
+12
View File
@@ -1,15 +1,27 @@
/**
* 提供旧卡片兼容布局与原生测量布局的连线锚点。
* 两种协议不能混猜:compat-v1 依卡片边框/ports 排布,record-v1 应使用 renderer 实测 Handle。
*/
import type {CanvasGeometryNode} from './compat-types.ts';
import type {ProjectionNode} from './projection.ts';
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
/** 兼容几何节点加投影 data,保留 measured 尺寸和旧卡片端口声明。 */
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */
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);
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
if(port){const peers=node.data.ports!.filter(item=>item.direction===port.direction);result.point.y+=((peers.indexOf(port)+1)/(peers.length+1)-.5)*(node.measured?.height||node.height||400);}return result;
}
/** renderer 测量端口:绝对位置与 source/target handle 边界,避免在这里依赖整个 UI store。 */
export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
/** 单个 Handle 在节点中的相对矩形及所在边。 */
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. */
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
* 指定 handleId 未找到或尚无测量时返回 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 {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]};
@@ -1,9 +1,14 @@
/**
* 按元素 ID 精确路由的只读查询源。一个上游 editor 订阅服务所有元素桶;
* 普通提交用 changes 找受影响 ID,整份文档替换或加载覆盖变化则做必要的保守复查。
*/
import type {Commit,Id} from '../canvas-core/types.ts';
import type {ElementLookup} from '../canvas-core/queries.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
/** 注入根读取、发布订阅与元素查询,绑定层不另建可以编辑的元素表。 */
export type ElementSourcePorts={
getSnapshot():EngineSnapshot;
subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void;
@@ -11,18 +16,23 @@ export type ElementSourcePorts={
};
/** One upstream subscription, ID buckets and cached lookup values, never another document.
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */
export function createElementSources(ports:ElementSourcePorts){
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
const buckets=new Map<Id,Bucket>();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined;
/** 先更新全部命中桶再通知,逐个隔离监听器错误,最终重抛首个错误给上游边界。 */
function changed(commit:Commit,next:EngineSnapshot){
// The editor snapshots its listeners; this callback can already be unsubscribed.
if(!previous||!buckets.size)return;
const before=previous!;previous=next;
const ids=new Set<Id>();
if(before.document!==next.document){
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。
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);
}
// loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。
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);
const notifications=new Set<()=>void>();
@@ -36,15 +46,20 @@ export function createElementSources(ports:ElementSourcePorts){
for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}
if(failed)throw error;
}
/** 创建单元素外部源;单纯创建源不注册上游,只有 subscribe 才建立观察关系。 */
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
let value:ElementLookup|undefined;
return Object.freeze({
/** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
subscribe(notify:()=>void){
let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);}
// Distinct subscriptions may intentionally share the same callback identity.
const listener=()=>notify();bucket.listeners.add(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;}}
// 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。
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;}};
},
});
+13
View File
@@ -1,22 +1,35 @@
/**
* 显示层的帧合并队列。把多次“需要重画”合并成去重 key 集合,不延迟命令提交、历史或网络任务。
* 队列只拥有调度句柄和失效索引;真实数据随时从上游快照读取,dispose 时丢弃未发布通知。
*/
import type {ExternalSource} from './selectors.ts';
/** Presentation invalidations only. Commands, history and network work never wait here. */
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */
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({
/** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */
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,
/** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */
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. */
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
* 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */
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){
const queue=createFrameQueue<number>(clock,()=>notify());
let stop:()=>void;
// 注册抛错也要撤销可能已经安排的帧,避免一个未建立成功的订阅仍在稍后通知。
try{stop=source.subscribe(immediate=>{queue.invalidate([0]);if(immediate)queue.flush();});}catch(error){queue.dispose();throw error;}
// 先退订上游,finally 保证上游清理抛错时仍释放本订阅的显示队列。
return()=>{try{stop();}finally{queue.dispose();}};
}});
}
+15
View File
@@ -1,21 +1,36 @@
/**
* 视图局部的数值/路径投影缓存。按元素代次、几何和变换值复用计算,既不缓存可编辑记录,也不决定可见性。
* 每次 begin/end 标记本帧访问项,剔除不再出现的条目;切换 scope/project/canvas 时整体清空。
*/
import type {Geometry,Id,Json} from '../canvas-core/types.ts';
import {childCanvasTransform,toWorld,transformPath,type Transform} from '../canvas-core/coordinates.ts';
import {equal,clone,freeze} from '../canvas-core/values.ts';
/** 仅比较投影使用的四个变换量,普通对象引用变化不必导致重新计算。 */
const sameTransform=(a:Transform,b:Transform)=>a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
/** 节点几何的全部数值字段都参与缓存条件。 */
const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
/** 子画布变换只依赖 presentation 中 contentScale/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. */
/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */
export function createGeometryProjectionCache(){
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};
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({
/** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */
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;},
/** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */
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 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
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(){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 不重置累计计数。 */
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
});
}
/** 由工厂推导的缓存接口,供投影器注入复用。 */
export type GeometryProjectionCache=ReturnType<typeof createGeometryProjectionCache>;
+30
View File
@@ -1,3 +1,8 @@
/**
* 对已捕获显示投影执行命中查询,而不是直接扫描请求队列或另建选择模型。
* 节点用组件 geometry 协议精测,连线用实际显示锚点/曲线近似求距;像素容差按 zoom 转为场景单位。
* 预算或几何解释不足时保留 diagnostics/possible,调用方不能把不完整结果当成确定的“没有命中”。
*/
import type {DocumentQueries} from '../canvas-core/queries.ts';
import type {ElementRecord,Geometry,Id,Point,Registry,Target} from '../canvas-core/types.ts';
import type {Connector,Cubic} from '../canvas-core/geometry.ts';
@@ -7,35 +12,60 @@ import {toLocal,type Transform} from '../canvas-core/coordinates.ts';
import {fault,freeze} from '../canvas-core/values.ts';
import {compatConnector} from './connectors.ts';
import type {projectCanvas,ProjectionNode,ProjectionEdge} from './projection.ts';
/** 查询消费 projectCanvas 的同一帧结果,包含显示节点、边与 canvas→场景变换。 */
type Projection=ReturnType<typeof projectCanvas>;
/** 命中身份用真实元素 ID+代次;displayPath 说明嵌套显示路径,不改变 ownerCanvasId。
* 连线另保留屏幕显示端点身份,可能是投影暴露的上级入口,而不是底层真实端点。 */
export type SceneHit={readonly target:Target;readonly kind:'node'|'connection';readonly ownerCanvasId:Id;readonly displayPath:{readonly viewSessionId:Id;readonly canvasIds:readonly Id[];readonly entryIds:readonly Id[]};readonly layer:number;readonly certainty:'confirmed'|'possible';readonly distanceScene?:number;readonly errorScene?:number;readonly displayedEndpoints?:{readonly source:Id;readonly target:Id}};
/** renderer 可提供实际锚点、曲线、线宽与层级,使命中跟绘制一致;previewIds 补充移动预览候选。 */
export type HitServices={readonly anchor?:(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>Connector|undefined;readonly edgeGeometry?:(edge:ProjectionEdge,source:Connector,target:Connector)=>readonly Cubic[]|undefined;readonly strokeWidth?:(edge:ProjectionEdge)=>number|undefined;readonly layer?:(id:Id,kind:'node'|'connection')=>number|undefined;readonly previewIds?:ReadonlySet<Id>};
/** 候选数、曲线步数/深度、每边段数与像素误差上限,限制一次交互查询的工作量。 */
export type HitBudget={readonly maxCandidates?:number;readonly maxCurveSteps?:number;readonly maxCurveDepth?:number;readonly maxSegments?:number;readonly errorPx?:number};
/** scene 点直接附缩放;screen 点还带容器 origin 和 viewport 平移,避免混用窗口/画布坐标。 */
export type HitPoint=({readonly space:'scene';readonly point:Point;readonly zoom:number}|{readonly space:'screen';readonly point:Point;readonly viewport:{readonly x:number;readonly y:number;readonly zoom:number;readonly origin:Point}})&{readonly tolerancePx?:number};
/** 外部几何服务的点必须由有限数值组成,不能让 NaN/Infinity 流入曲线计算。 */
const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFinite(point.y);
/** 把显示节点的位置/尺寸/旋转反算回所属 canvas 局部几何,包含当前预览后的显示结果。 */
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. */
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
// 强制快照/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(!finitePoint(query.point)||!Number.isFinite(zoom)||zoom<=0||!Number.isFinite(tolerancePx)||tolerancePx<0||!Number.isFinite(errorPx)||errorPx<=0||![maxCandidates,maxSteps,maxDepth,maxSegments].every(n=>Number.isSafeInteger(n)&&n>=1&&n<=1000000)||maxDepth>40)throw fault('invalid_hit_query');
if(query.space==='screen'&&(!finitePoint(query.viewport.origin)||!Number.isFinite(query.viewport.x)||!Number.isFinite(query.viewport.y)))throw fault('invalid_hit_query');
// 屏幕位置减容器 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;
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
// 分别把场景点与容差变回各 canvas,查询本地节点索引;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);}
/** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */
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;};
// 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。
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&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
// 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。
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'});
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
// 优先采用 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');
// 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。
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'&&registry.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 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。
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;不把只点中心线误当成整条可见线的命中范围。
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。
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;只有不确定命中则同时说明精度或预算诊断。
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'});}}
// 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
// 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。
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});
}
+9
View File
@@ -1,17 +1,25 @@
/**
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
*/
/** Portable scene projection and hit queries: no React or React Flow imports. */
// 模型到场景投影、几何缓存和受预算控制的命中查询。
export * from './projection.ts';
export * from './geometry-cache.ts';
export * from './projection-cache.ts';
export * from './hit-query.ts';
// 框架无关 renderer 描述、锚点约定与资源视图租约。
export * from './renderers.ts';
export * from './connectors.ts';
export * from './resource-view.ts';
export type * from './compat-types.ts';
// 外部快照选择与输入优先级;这些工具不建立第二份业务状态。
export {createSelectorStore} from './selectors.ts';
export type {ExternalSource} from './selectors.ts';
export {routeCanvasKey,canvasKeyContext} from './input-router.ts';
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
// 仅显示失效通知的帧合并和绘制范围索引,业务命令不在这里排队。
export {createFrameQueue,createFrameSource} from './frame-queue.ts';
export type {FrameClock} from './frame-queue.ts';
export {createSessionFrameSource} from './session-frames.ts';
@@ -19,6 +27,7 @@ export {createPaintIndex} from './paint-index.ts';
export type {PaintRegion} from './paint-index.ts';
export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts';
export {projectedConnectionPaintOverflow,createConnectionPaintIndex} from './connection-paint.ts';
// 视图局部浮层与 DOM 指针捕获的生命周期工具。
export {createOverlayIndex} from './overlays.ts';
export type {OverlayEntry,OverlayIndex} from './overlays.ts';
export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts';
+13
View File
@@ -1,14 +1,26 @@
/**
* 归一化键盘事件的消费顺序:文本编辑 → 浮层 → 手势 → 工具 → 快捷键。
* 输入来源由宿主传入,路由器只报告获胜层,不直接 preventDefault、执行命令或访问浏览器全局。
*/
/** Host normalizes DOM/native input; only the winning layer consumes an event. */
/** 将 DOM/native 键盘上下文压成只读值;composing 保护输入法组合,modifier 供具体处理器解释。 */
export type CanvasKeyInput={readonly key:string;readonly composing?:boolean;readonly textEditing?:boolean;readonly overlay?:boolean;readonly defaultPrevented?:boolean;readonly ctrl?:boolean;readonly meta?:boolean;readonly alt?:boolean;readonly shift?:boolean};
/** 只有一个层获得消费权;unclaimed 表示宿主不应据此拦截事件。 */
export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed';
/** 返回 true 即获得事件;后续低优先级处理器不会再运行。 */
export type KeyHandler=(input:CanvasKeyInput)=>boolean;
// 标准文本控件、可编辑内容和 textbox 角色均视为正在输入,不让画布快捷键抢走按键。
const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]';
// 画布浮层标记和交互 ARIA 角色优先于全局画布命令。
const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"]';
/** 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. */
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
let textEditing=false,overlay=false;
for(const target of [event.target,...(event.composedPath?.()??[])]){
// 使用结构能力判断,跳过 Window、文本节点等无 closest 的路径项,无需 instanceof Element。
if(!target||typeof (target as {closest?:unknown}).closest!=='function')continue;
const element=target as {closest(selector:string):unknown};
textEditing ||= !!element.closest(textTarget);
@@ -16,6 +28,7 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:(
}
return {textEditing,overlay};
}
/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
if(input.defaultPrevented)return 'unclaimed';
if(input.composing||input.textEditing)return 'text';
@@ -1,15 +1,24 @@
/**
* 把节点端口/尺寸变化的测量请求合并给 renderer,避免同一轮重复调用更新。
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
*/
/** One renderer store's transient measurement requests. Not model or history state. */
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */
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;
/** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */
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();
if(ids.size)update([...ids]);
}
return Object.freeze({
/** 每次挂载取得独立租约,即使节点 ID 相同也能单独 dispose。 */
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
dispose(){owners.delete(owner);dirty.delete(owner);},
});
},
+14
View File
@@ -1,17 +1,31 @@
/**
* 视图局部的屏幕空间浮层索引。保存位置、目标代次与焦点/输入法状态,独立于业务文档和撤销历史。
* 同 ID 浮层重新注册时获得新 token,旧组件迟到清理不能删掉新浮层。
*/
import type {Target} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {createPaintIndex} from './paint-index.ts';
import {fault} from '../canvas-core/values.ts';
/** 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};
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */
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();
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */
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 索引校验矩形,拒绝不合法的外部边界。 */
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 返回稳定的最近发布值。
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
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();}});},
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */
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();},
});
}
/** 由工厂推导接口,调用方无权直接操作内部 token/Map。 */
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
+13
View File
@@ -1,18 +1,31 @@
/**
* 显示空间的可重建均匀网格,用于额外绘制范围的可见性查询。
* 大区域、大查询或超出安全格坐标时用保守回退,避免为了空间索引速度漏掉可见元素。
*/
import type {Id} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {fault} from '../canvas-core/values.ts';
/** 区域 ID 与场景轴对齐矩形;不保存节点内容、选择或交互状态。 */
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
/** 同时校验端点,防止有限 x/width 相加后溢出;允许零面积边界。 */
const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0;
/** 包含边界接触的保守交集,与 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;
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */
export function createPaintIndex(cellSize=512,maxCells=256){
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>();
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 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 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({
/** 先验证完整输入的 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);}}},
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */
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();},
});
}
@@ -1,5 +1,14 @@
/**
* 浏览器指针捕获的容错边界。这里只处理 DOM 事件传输所有权;手势 token、预览和几何仍归 editor/session。
* DOM 捕获丢失不等于任意事件都能取消当前手势,必须匹配指针与捕获目标。
*/
/** Transport ownership only: document geometry and gesture tokens stay in the editor. */
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */
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 acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
/** 尽力释放且吞掉平台清理异常;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. */}}
@@ -1,3 +1,7 @@
/**
* 节点额外绘制范围的保守索引。原生 renderer 的未旋转矩形不一定包含旋转/自定义组件的全部笔画,
* 因此用 geometry.paint bounds 补充超出的范围;无法解释的节点标 unknown,不能当作已证明不可见。
*/
import {clone,equal} from '../canvas-core/values.ts';
import {rectangleGeometry} from '../canvas-core/geometry.ts';
import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts';
@@ -5,13 +9,17 @@ import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-c
import type {projectCanvas,ProjectionNode} from './projection.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{
const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry');
const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360};
// 未知/降级节点用标准矩形边界;有效组件可声明更宽的笔画或旋转后的范围。
const definition=!node.data.__headlessFallback&&registry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=definition?.kind==='node'?definition:rectangleGeometry;
const local=protocol.bounds({element,geometry,role:'paint'});if(![local.x,local.y,local.width,local.height].every(Number.isFinite)||local.width<0||local.height<0)throw Error('invalid_paint_bounds');
// 局部边界再经分区变换,必须考虑父级旋转/缩放,不能只给原矩形加一个固定像素边距。
const points=[{x:local.x,y:local.y},{x:local.x+local.width,y:local.y},{x:local.x,y:local.y+local.height},{x:local.x+local.width,y:local.y+local.height}].map(p=>toWorld(transform,p)),x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y)),right=Math.max(...points.map(p=>p.x)),bottom=Math.max(...points.map(p=>p.y));
if(![x,y,right,bottom,right-x,bottom-y].every(Number.isFinite))throw Error('invalid_paint_bounds');
if(x<node.position.x||y<node.position.y||right>node.position.x+node.width!||bottom>node.position.y+node.height!)regions.push({id:node.id,bounds:{x,y,width:right-x,height:bottom-y}});
@@ -19,10 +27,16 @@ export function projectedPaintOverflow(projection:ReturnType<typeof projectCanva
return{regions,unknown};
}
/** 只接节点几何协议;连线范围有独立的曲线/线宽路径。 */
type NodeProtocol=Extract<GeometryProtocol,{kind:'node'}>;
/** 缓存签名覆盖生命周期、模型几何、显示预览、变换、协议身份和其声明的数据依赖。 */
type PaintSignature={incarnation?:number;owner?:string;component?:string;version?:number;geometry?:Geometry;transform?:Transform;x:number;y:number;width?:number;height?:number;rotation:unknown;protocol:NodeProtocol;data:unknown};
/** 可缺失几何按全数值比较,外部对象换引用不等于显示几何改变。 */
const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
/** 变换值比较避免无关根发布反复计算 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;
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
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;
const dependencies=protocol.dataDependencies;
@@ -31,30 +45,37 @@ function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRec
for(let i=0;i<dependencies.length;i++)if(!equal(values[i],element?.data[dependencies[i]]))return false;
return true;
}
/** 复制实际依赖值而非持有调用方可变子对象;后续比较用捕获值发现内容变化。 */
function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol):PaintSignature{
const dependencies=protocol.dataDependencies;
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. */
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
export function createProjectedPaintIndex(){
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
type Projection=ReturnType<typeof projectCanvas>;
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
const entries=new Map<string,Entry>(),index=createPaintIndex();
return Object.freeze({
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */
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;}
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&&registry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry;
let entry=old;
// 单节点计算复用完整算法,保证增量缓存与无缓存查询使用相同几何规则。
if(!entry||!sameSignature(entry.signature,node,element,t,protocol)){
const signature=captureSignature(node,element,t,protocol),result=projectedPaintOverflow({...projection,nodes:[node]},registry);captured++;computed++;entry={signature,result};entries.set(node.id,entry);
}
regions.push(...entry.result.regions);unknown.push(...entry.result.unknown);
}
// 删除已不在场景中的缓存,返回可查询索引与冻结的未知列表;调用方须保守处理 unknown。
for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)});
},
/** 累计实际计算/捕获次数和当前项数,仅供诊断,不是帧耗时或文档 revision。 */
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
/** 解除场景与 registry 身份引用并清空网格;累计诊断计数仍保留。 */
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
});
}
@@ -1,15 +1,25 @@
/**
* React Flow 输入对象的结构共享缓存,与计算场景的 scene-projection 分工不同。
* 按元素代次与 owner 判定同一生命周期,复用值相等的 data/style/position 和整个数组,减少显示刷新。
* 缓存属于一个 owner/project/视图 canvas,既不是业务文档,也不是跨项目身份仓库。
*/
import type {Document,Id} from '../canvas-core/types.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {projectCanvas} from './projection.ts';
/** 只处理已经生成的投影,不在这里解释元素组件或计算坐标。 */
type Projection=ReturnType<typeof projectCanvas>;
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
export function createProjectionCache(){
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|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;
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
const next=new Map<Id,Retained<T>>();
for(let index=0;index<values.length;index++){
@@ -17,6 +27,7 @@ export function createProjectionCache(){
const old=previous.get(value.id),sameLifetime=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId;
if(sameLifetime&&old.value===value){next.set(value.id,old);continue;}
let shared=value;
// 常见嵌套字段独立共享,使其它显示属性变化时也能保留未变化的 data/style/position。
if(sameLifetime)for(const key of ['data','style','position'] as const){const incoming=(shared as Record<string,unknown>)[key],before=(old.value as Record<string,unknown>)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};}
const retained=sameLifetime&&equal(old.value,shared)?old.value:shared;
values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained});
@@ -24,17 +35,21 @@ export function createProjectionCache(){
return next;
}
return Object.freeze({
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */
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;}
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges);
return{...projection,nodes:nodeArray,edges:edgeArray};
},
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
getNode(id:Id){return nodes.get(id)?.value;},
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
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);
},
/** 清空对象/数组和范围身份,调用方无需也不应据此清除业务文档。 */
clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;},
});
}
+30
View File
@@ -1,3 +1,8 @@
/**
* 把编辑器文档与瞬态手势投影成 renderer 可消费的节点、边与坐标变换。
* 模型 ID/代次/owner 保留原意;折叠分区只改变显示端点,不迁移连线归属或修改真实引用。
* 同一套 projectNode/projectEdge 被完整遍历与增量补丁共用,避免两条路径产生不同显示规则。
*/
import type {DocumentQueries} from '../canvas-core/queries.ts';
import type {GeometryProjectionCache} from './geometry-cache.ts';
import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts';
@@ -9,21 +14,30 @@ import type {CanvasNodeData,EdgeData} from './compat-types.ts';
import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts';
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
const origin:Transform={x:0,y:0,scale:1,rotation:0};
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */
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 变换。 */
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}};
/** 显示端点可以指向折叠入口;record-v1 把路径解释留给 renderer,compat-v1 接已转换路径。 */
export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
/** 仅将 JSON 对象当作 presentation 读取,空值/数组不推测为有效展示配置。 */
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */
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 width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
const renderer=renderers.resolve(element);
// 组件未知、版本不支持、renderer 缺失是不同失败边界;降级时不展开任意组件数据给旧 renderer。
const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined;
const type = fallback ? 'unsupported' : renderer!.rendererId;
const data = element.data;
// 几何统一在场景单位;frame 层序按深度调整,兼容 frame 的内容区不截住其内部元素点击。
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
@@ -32,8 +46,11 @@ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,ca
}
/** Endpoints are already resolved to their displayed node IDs by the caller. */
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
* 预览只替代本条边的路径;不把 data 内可能残留的 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{
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform));
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
@@ -41,15 +58,21 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
}
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */
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]);
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');if(queries&&queries.registry!==registry)throw fault('query_registry_changed');cache?.begin(services.owner??cache,document.projectId,canvasId);
// transforms 只包含展开范围;hiddenBy 把所有折叠后代 canvas 指向当前可见的入口节点。
const transforms = new Map<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id);
const members = new Map<Id, ElementRecord[]>();
if(!queries)for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
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 visit = (id: Id, depth: number) => {
const transform = transforms.get(id)!;
for (const element of getMembers(id)) if (element.structure === 'node') {
@@ -58,6 +81,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
nodes.push(projected); visible.add(element.id);
if (child) {
const presentation = object(data.presentation);
// 未知入口无法安全解释子分区展示规则,也按折叠处理;后代都映射到这一可见入口。
if (!!fallback || presentation.collapsed === true) {
const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); if(queries)stack.push(...queries.childCanvases(hidden));else for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); }
} else {
@@ -67,14 +91,17 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
}
};
visit(canvasId, 0);
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。
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)) {
/** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */
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);
if (!source || !target || source === target) continue;
edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache));
}
// Handles come from known envelope endpoints, never from opaque component payloads.
// 降级把手只取标准方向与边结构的端口引用,不从未知组件的任意 payload 推测端口声明。
const nodesById=new Map(nodes.map(node=>[node.id,node]));
const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!]));
const handleIds=new Map<Id,Set<string>>();
@@ -82,6 +109,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
for(const edge of edges)for(const end of ['source','target'] as const){
const fallback=fallbackNodes.get(edge[end]);
if(!fallback){
// 未知边接兼容节点时只保留实际已知 handle,防止 renderer 寻找不存在的 DOM 端口。
if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){
const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field];
if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);}
@@ -89,9 +117,11 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
}
continue;
}
// 降级节点可补出边明确引用的把手,分配一个可显示的方向;这是显示外壳,不更改模型 portId。
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'});}
}
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。
cache?.end();
return { document,canvasId,nodes, edges, transforms };
}
+7
View File
@@ -1,5 +1,10 @@
/**
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
*/
"use client";
/** Explicit framework boundary; consumers install the declared optional peers. */
// 显示组件注册、命令注入和业务快照 hooks,均由应用在客户端装配。
export * from './react-renderers.ts';
export * from './renderer-context.ts';
export * from './react.ts';
@@ -7,5 +12,7 @@ export * from './react.ts';
import type {Node,Edge} from '@xyflow/react';
import type {ProjectionNode,ProjectionEdge} from './projection.ts';
/** Structural adapter types add framework-owned optional metadata, never model state. */
/** 在可移植节点投影上叠加框架可选元数据,不把测量/选择等框架字段写回文档。 */
export type ReactFlowProjectionNode=Node<ProjectionNode['data'],string>&ProjectionNode;
/** 连线的框架适配类型;真实端点和 owner 的权威信息仍在元素记录中。 */
export type ReactFlowProjectionEdge=Edge<ProjectionEdge['data']>&ProjectionEdge;
@@ -1,26 +1,41 @@
/**
* 把可移植 renderer 描述与受信 React 组件装配为 React Flow 类型表。
* 验证版本/结构及 rendererId 冲突,并通过弱缓存稳定 memo 包装身份;不实例化 editor 或元素模型。
*/
import {memo} from 'react';
import type { NodeTypes, EdgeTypes } from '@xyflow/react';
import type { ComponentDefinition, Registry } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } from './renderers.ts';
/** 节点/连线分别提供对应组件,可另提供渐进预览组件;模型解释协议仍由 RendererDescriptor 决定。 */
export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string];readonly preview?:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string];readonly preview?:EdgeTypes[string]});
/** 冻结的装配结果,普通与 preview 类型表分开交给各显示路径。 */
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes};
/** 应用扩展可独立安装模型定义与显示实现;二者各自校验,不把 React 函数保存进元素数据。 */
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
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 compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
}
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */
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[]=[];
for(let index=0;index<definitions.length;index++){
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,preview=definition.preview,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
// 接受函数及 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');
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 不兼容。
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;
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。
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)});
+47
View File
@@ -1,3 +1,8 @@
/**
* React 对项目 engine 的借用层:订阅读取统一根、按目标代次发命令、管理本组件的手势/显示租约。
* Provider 不拥有项目生命周期,卸载只清理本 hook 的订阅与租约,不关闭共享 engine。
* useSyncExternalStore 读取稳定派生快照;可恢复业务事实始终由 editor/runtime 保存。
*/
"use client";
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
import {createSelectorStore,type ExternalSource} from './selectors.ts';
@@ -12,45 +17,68 @@ import type {ElementLookup} from '../canvas-core/queries.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {DisplayHandle} from '../canvas-runtime/resources.ts';
/** 绑定一个 engine 和可选已 attach 的视图;纯文档查询不一定需要 viewId,交互模式/手势需要。 */
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
const CanvasContext=createContext<CanvasBinding|null>(null);
/** Borrows the engine and an already attached view. Unmount never closes the project. */
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */
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 时抛出稳定领域错误交给应用错误边界。 */
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */
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 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 value=useSyncExternalStore(store.subscribe,store.getSnapshot,store.getSnapshot);
useEffect(()=>{committed.current={value};},[value]);return value;
}
/** 对 engine memo 化根源;已关闭实例不再注册 editor 监听,读取仍来自该实例的最终快照。 */
function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);}
/** 按选择器订阅根的一部分,默认 Object.is,调用方可给明确等价判断避免无关刷新。 */
export function useCanvas<T>(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);}
/** 按 Target 查询时即使同 ID 有新元素也返回 stale,明确提示旧代次绑定失效。 */
export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord};
// 每 engine 共享按 ID 路由器,WeakMap 不额外持有已无人引用的项目;不是每个 hook 一份根表。
const elementSources=new WeakMap<ProjectEngine,ReturnType<typeof createElementSources>>();
/** 惰性装配元素查询桶;lookup 使用指定根文档对应的 editor 查询,避免新旧快照错配。 */
function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(engine);if(!sources){sources=createElementSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify),lookup:(root,id)=>engine.editor.getQueries(root.document).lookupElement(id)});elementSources.set(engine,sources);}return sources;}
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
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;
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */
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 source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
return useSelected(source,useCallback((found:ElementLookup)=>{const view=found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;return selector?selector(view):view;},[incarnation,selector]),(isEqual as ((a:T|ElementView,b:T|ElementView)=>boolean)|undefined)??(selector?Object.is:equal));
}
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */
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 在统一边界处理。
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
setData:(patch:Data,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch},options),
resize:(patch:Partial<Geometry>,options?:ActionOptions)=>engine.editor.dispatch({type:'element.geometry',target:ref,patch},options),
remove:(options?:ActionOptions)=>engine.editor.dispatch({type:'element.remove',target:ref},options),
moveTo:(canvasId:Id,geometry?:Geometry,options?:ActionOptions)=>engine.editor.dispatch({type:'element.move',target:ref,canvasId,...(geometry?{geometry}:{})},options),
});},[engine,id,incarnation]);}
// 模式与手势共享每项目的按 ID 会话路由器,不缓存完整 session 到每个 hook。
const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSources>>();
/** 惰性绑定 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 pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
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;}}};
}
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */
export function useMode(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
@@ -58,36 +86,55 @@ export function useMode(target:Target){
const setMode=useCallback((mode:string|null):Availability=>{try{if(engine.getSnapshot().document.elements.get(id)?.incarnation!==incarnation)throw fault('stale_incarnation',[id]);engine.sessions.mode(viewId,id,mode);return{enabled:true};}catch(error){return available(error);}},[engine,viewId,id,incarnation]);
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
}
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */
export function useGestureBindings(target: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]);
// 有匹配的节点记录才提供几何,预览优先;active 来自 session 的手势所属视图判定。
const state=useSelected(source,useCallback(([found,transient]:readonly[ElementLookup,GestureSelection])=>({geometry:found.status==='present'&&found.element.incarnation===incarnation&&found.element.structure==='node'?transient.preview??found.element.geometry:undefined,active:transient.active}),[incarnation]),equal);
const controls=useMemo(()=>{
let lease:number|null=null;
/** token + view + active phase 三重检查,不仅凭元素 ID 判断控制权。 */
const owned=()=>lease!==null&&engine.sessions.getSnapshot().gesture?.token===lease&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active';
return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial<Geometry>):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease=null;}});
},[engine,viewId,id,incarnation]);
// 控制对象因目标/视图变化而替换时也释放旧租约;成功 commit 的 finally 已清掉本地 token。
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
}
// operation/资源元数据/计数在同 engine 下共用分桶订阅,避免全项目每个 hook 扫一遍任务表。
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
/** 惰性建立 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;}
/** 单操作诊断读取,返回逻辑操作及关联冻结请求,不提供直接改任务状态的方法。 */
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/任务结果分开观察。 */
type Durability=ReturnType<ProjectEngine['getDurability']>;
/** dirty 只说明 rootRevision 超过可靠保存根,不能用它单独判定网络同步是否结束。 */
const syncDurability=(value:Durability)=>({dirty:value.rootRevision>value.durableRootRevision,error:value.error,closing:value.closing,closed:value.closed});
/** 关闭中或已关闭均禁止继续借出新的显示句柄。 */
const inactiveOwner=(value:Durability)=>value.closing||value.closed;
/** 使用 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);}
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */
export function useSyncStatus(canvasId?:Id){
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 dirty=durable.dirty;
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 显式重取。
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */
export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
// 本地上传身份/清除状态、共享授权域/摘要是显示来源的关键身份,不按 URL 字符串猜生命周期。
const key=JSON.stringify([metadata.record?.operationId,metadata.record?.retirement?.purgedAt,metadata.binding?.authority,metadata.binding?.digest]);
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
// 清理中止本次读取并归还 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]);
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */
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]);
}
@@ -1,9 +1,17 @@
/**
* React renderer 的受控命令注入点。renderer 借用 editor 的普通 dispatch/transaction,
* 不取得直接修改文档表或绕过校验的权限;上下文缺失时明确报错,避免默默丢编辑。
*/
"use client";
import { createContext, useContext } from 'react';
import type { Transaction } from '../canvas-core/editor.ts';
import type { Command, Result } from '../canvas-core/types.ts';
/** 与普通历史单元一致的标签/分组信息,不是网络请求参数。 */
export type RendererCommandOptions={label?:string;groupId?:string};
/** 最小写入门面:单命令或同步逻辑事务都返回 editor 的校验/提交结果。 */
export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCommandOptions):Result;transaction(callback:(transaction:Transaction)=>unknown,options?:RendererCommandOptions):Result};
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
/** Trusted installed renderers submit normal validated commands, never write a second model. */
/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
+13
View File
@@ -1,8 +1,17 @@
/**
* 可移植的 renderer 描述表,只声明哪种元素由哪个显示实现处理,不保存 React 组件或元素实例状态。
* 按 componentId/dataVersion/structure 精确匹配;模型 registry 能解释数据,不代表宿主一定安装了对应 renderer。
*/
import type { ElementRecord,Registry } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
/** rendererId 是宿主显示类型名;record-v1 读取原记录,compat-v1 使用旧卡片投影,默认采用前者。 */
export type RendererDescriptor={readonly componentId:string;readonly dataVersion:number;readonly structure:'node'|'connection';readonly rendererId:string;readonly projection?:'compat-v1'|'record-v1'};
/** 冻结定义列表与只读解析入口;没有匹配项返回 undefined,交给上层选择降级外壳。 */
export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];resolve(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>):RendererDescriptor|undefined};
/** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
/** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。
* 工厂不加载代码、不自动迁移 dataVersion,也不把高版本当成兼容旧版本。 */
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
const entries=new Map<string,RendererDescriptor>();
for(const value of definitions){
@@ -11,12 +20,16 @@ export function createRendererCatalog(definitions:readonly RendererDescriptor[])
}
return Object.freeze({definitions:Object.freeze([...entries.values()]),resolve:(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>)=>entries.get(key(element))});
}
/** 内置旧卡片 renderer 的版本1映射,明确保留 compat-v1,避免默认原生投影破坏旧组件输入。 */
export const builtinRendererDefinitions:readonly RendererDescriptor[]=Object.freeze([
...['text','image','script'].map(componentId=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId:'material',projection:'compat-v1' as const})),
...Object.entries({'plain-text':'plainText','plain-image':'plainImage',frame:'frame',icon:'icon'}).map(([componentId,rendererId])=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId,projection:'compat-v1' as const})),
Object.freeze({componentId:'connection',dataVersion:1,structure:'connection',rendererId:'default',projection:'compat-v1'}),
]);
/** 可复用的不可变内置描述目录,不含任何 editor 的可变状态。 */
export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinitions);
/** Installed renderers without a reference declaration wait for full context. */
/** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。
* 保留 definitions 供装配检查,但 resolve 才决定当前是否允许显示实现。 */
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
+19
View File
@@ -1,22 +1,36 @@
/**
* 一个视图的元素→资源显示租约管理。按元素保留未变化的已加载 URL,各图片独立完成就独立发布。
* 这里只借用资源句柄;不拥有资源原件、上传任务或业务文档,视图销毁应释放租约而非删除资源。
*/
import type { Id } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
import type { DisplayHandle } from '../canvas-runtime/resources.ts';
/** 一个元素当前一次解析尝试,控制器和记录对象身份共同隔离迟到结果。 */
type Record = { readonly resource: Id; readonly controller: AbortController; handle?: DisplayHandle; failed: boolean };
/** 对界面只暴露 URL 及对应资源 ID,避免只按元素 ID 把旧图片结果应用到新资源。 */
export type ResourceViewSource = { readonly resourceId: Id; readonly url: string };
/** A view owns subscriptions/leases, never a resource or document store. Retain
* unchanged nodes, publish each ready source independently, and fence late work.
*/
/** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。
* sync 输入是完整目标集合,retry 只重建失败项,dispose 幂等释放所有在途/已完成句柄。 */
export function createResourceView(options: {
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
onError?(error: unknown): void;
}) {
// records 是当前尝试表,targets 保留最近一次期望集合供 retry;同资源用于不同元素仍各有租约。
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
/** 错误展示抛错不影响租约释放或其它图片的加载。 */
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
/** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
/** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
/** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
/** 完整验证元素/资源 ID 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */
function sync(next: readonly (readonly [Id, Id])[]) {
if (disposed) throw fault('resource_view_disposed');
const desired = new Map<Id, Id>();
@@ -28,13 +42,16 @@ export function createResourceView(options: {
for (const [id, record] of records) if (desired.get(id) !== record.resource) { records.delete(id); release(record); changed = true; }
for (const [id, resource] of desired) if (!records.has(id)) {
const record: Record = { resource, controller: new AbortController(), failed: false }; records.set(id, record); changed = true;
// 延到微任务发起解析,先确认该尝试仍是当前记录;不是简单按 ID 接受任意迟到 URL。
void Promise.resolve().then(() => {
if (disposed || records.get(id) !== record) throw fault('display_aborted');
return options.resolve(resource, record.controller.signal);
}).then(handle => {
// 即使底层不响应 abort,旧尝试成功返回也必须立刻 release,不能挂到新元素状态上。
if (disposed || records.get(id) !== record) { releaseHandle(handle); return; }
record.handle = handle; publish();
}, error => {
// 取消/换源的旧失败不增加失败数;真正当前失败才发布并允许 retry。
if (disposed || records.get(id) !== record || record.controller.signal.aborted) return;
record.failed = true; publish(); report(error);
});
@@ -43,7 +60,9 @@ export function createResourceView(options: {
}
return Object.freeze({
sync,
/** 移除失败记录后按 targets 重新 sync;成功项不重新取 URL。 */
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
/** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
});
}

Some files were not shown because too many files have changed in this diff Show More