Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abc8247710 | |||
| 536e4dbaa1 | |||
| 772ce80ad2 | |||
| bb84e8a4da | |||
| 484fcbef49 | |||
| f0c7c83b24 | |||
| 357c92d749 | |||
| 7ac78e1191 | |||
| 48ef0dc896 | |||
| 11735d3d78 | |||
| bf89a1da2e | |||
| a582052a1e | |||
| 63aa3cf006 | |||
| 60338efe97 | |||
| 245f6e3254 | |||
| 05a41813f3 | |||
| 93f14af667 | |||
| 1defe9db90 | |||
| 15a66969cd | |||
| 74f64767fa | |||
| 535d963eaa | |||
| ea3df9877d | |||
| b768c2d6bf | |||
| 1c549724e8 | |||
| 2fa8b88f2c | |||
| 19bfbc772d | |||
| c43af2ccb9 | |||
| 7ed36b1ae0 | |||
| 225170b024 | |||
| eb287b8f23 | |||
| ad20013f0f | |||
| 2dbe68c6aa | |||
| 81055e14a1 | |||
| c735c00de8 | |||
| abf5ea9baa | |||
| e4e148502e | |||
| 0ea53f177f | |||
| 22638dd4ac | |||
| a2bfb0d9b5 | |||
| 33f870d16a | |||
| c311c7d7f8 | |||
| 923c82f366 | |||
| 62b41b79dc | |||
| 55d70fe786 | |||
| 7bd0bf2049 | |||
| 3ef07cd00c | |||
| d2068d416a | |||
| 97fefaa519 | |||
| c34d0b7ce0 | |||
| f28d3810d4 | |||
| 65c54e7b9e | |||
| cc2172319e | |||
| 5d82655e0e | |||
| 9b24319747 | |||
| b995e659a6 | |||
| 5803787910 | |||
| 84c50fe595 | |||
| f78618be2b | |||
| 18913efab7 |
@@ -26,6 +26,7 @@ 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先准备镜像/文件再一次同步发布。
|
||||
// 文件值直接保存传入 bytes 引用;示例要求宿主提供不可变字节载荷,不演示流式文件读写或增量日志。
|
||||
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;},
|
||||
@@ -59,6 +60,7 @@ const transport=async input=>{
|
||||
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
||||
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
|
||||
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
||||
// server 赋值在 validate 之前,断言失败不会恢复旧 server;此替身只用于下方合法协议路径,不是生产事务实现。
|
||||
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}};
|
||||
@@ -96,4 +98,5 @@ try{
|
||||
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}));
|
||||
// 顺序等待 close 后再检查 Storage;若 close 本身拒绝,storage.close 不会执行,错误向脚本调用者传播。
|
||||
}finally{await engine.close();storage.close();}
|
||||
|
||||
@@ -8,6 +8,7 @@ const config: NextConfig = {
|
||||
reactStrictMode: true, poweredByHeader: false,
|
||||
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
|
||||
webpack(config) {
|
||||
// 规则只按路径匹配,不按客户端/服务端分支;loader 自身再检查安装版本和四段源码锚点。
|
||||
config.module.rules.push({
|
||||
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
|
||||
use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }],
|
||||
@@ -25,6 +26,7 @@ const config: NextConfig = {
|
||||
},
|
||||
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
|
||||
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
|
||||
// 这里只匹配 URL 外观,不重新读取文件验证摘要;重生成文件名和旧资源保留由资源脚本/发布流程负责。
|
||||
async headers() {
|
||||
return [{
|
||||
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'});
|
||||
@@ -28,6 +29,7 @@ async function declarations(directory){
|
||||
}
|
||||
}
|
||||
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
|
||||
// 后处理逐文件直接写入,未使用临时目录整体 rename;中途错误可留下部分已改写的输出。
|
||||
await declarations(join(output,'modules'));
|
||||
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
|
||||
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。
|
||||
*/
|
||||
/** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */
|
||||
// 两张表都是共享的普通对象,const 不冻结字段;生成器只读取,修改映射后需显式重新生成目录。
|
||||
export const chinese = {
|
||||
"Star": "星星",
|
||||
"Heart": "爱心",
|
||||
|
||||
@@ -34,6 +34,7 @@ const categories = [
|
||||
/** 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提供路径,尚未在此写最终分片。
|
||||
// Map 以 PascalCase 名称去重,同名后项覆盖;不单独报告名称转换碰撞,输入包格式须按固定版本审查。
|
||||
const entries = new Map();
|
||||
for (const [slug, load] of Object.entries(dynamicIconImports)) {
|
||||
const name = pascal(slug);
|
||||
@@ -67,8 +68,10 @@ const catalog = records.map(({ node, ...entry }) => {
|
||||
});
|
||||
// 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。
|
||||
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
|
||||
// 输出在仓库当前工作目录逐文件写入,不是整体原子替换;失败时可能保留新分片和旧 manifest 的组合。
|
||||
await mkdir(assetDir, { recursive: true });
|
||||
let totalBytes = 0;
|
||||
// 文件名摘要用于资源版本命名,不把截断的12位摘要当作完整校验/授权凭据;消费者按 manifest 定位。
|
||||
const assets = {};
|
||||
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
|
||||
for (const [key, nodes] of shards) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// 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;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
|
||||
// require 在 loader 模块求值时读取一次版本,不在每个输入转换时重新扫描安装目录。
|
||||
const version = require('@xyflow/react/package.json').version;
|
||||
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
|
||||
module.exports = function retainVisibility(source) {
|
||||
@@ -20,6 +21,7 @@ module.exports = function retainVisibility(source) {
|
||||
}
|
||||
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
|
||||
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
|
||||
// 返回新字符串,不写磁盘;锚点检查只确认当前四段文本的形状,不证明升级后所有语义仍等价。
|
||||
return source
|
||||
.replace(nodes, `Array.from(new Set([
|
||||
...${nodes},
|
||||
|
||||
@@ -41,6 +41,7 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
|
||||
}
|
||||
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
|
||||
if (!size) return binary ? new Uint8Array() : {};
|
||||
// chunks 与合并 Buffer 在此可能同时占内存;limit 约束载荷字节,不是整个请求处理的堆内存上限。
|
||||
const bytes = Buffer.concat(chunks);
|
||||
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
|
||||
}
|
||||
@@ -67,6 +68,7 @@ async function handler(
|
||||
return json({ error: "invalid_origin" }, 403);
|
||||
}
|
||||
}
|
||||
// Origin 比对只使用 host(含端口),不要求 scheme 相同;也不在代理本地解析会话账户权限。
|
||||
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请求体上限。
|
||||
@@ -75,6 +77,7 @@ async function handler(
|
||||
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
|
||||
const canvas = canvasRoute.test(path);
|
||||
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
|
||||
// frozenAI 仅匹配 ai/runs 分支,ai/operations 查询不由此标记;新文档 model 路径整体由 canonical 覆盖。
|
||||
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
|
||||
let requestBody: unknown;
|
||||
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
|
||||
@@ -108,6 +111,7 @@ async function handler(
|
||||
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);
|
||||
// 不合规则的已知参数和名单外参数都静默不转发,不在这里返回 invalid_request。
|
||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||
}
|
||||
const encodingStarted = performance.now();
|
||||
@@ -119,6 +123,7 @@ async function handler(
|
||||
try {
|
||||
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
|
||||
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
|
||||
// canonical GET 包括操作回执查询;只有这些 GET 使用分层流超时,写入仍用下方15秒总时限。
|
||||
const streamedRead = snapshotRead || asset && req.method === "GET";
|
||||
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
|
||||
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
|
||||
@@ -151,12 +156,14 @@ async function handler(
|
||||
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";
|
||||
// 纯数字也可能超出安全整数,sessionMaxAge 此时回退默认30天;续期不代表这里验证了账户资料。
|
||||
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); }
|
||||
// MIME 白名单只看响应头,不解码图片或核对实际字节类型;内容权威校验属于上游资源协议。
|
||||
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
|
||||
const variant = upstream.headers.get("x-asset-variant");
|
||||
if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant);
|
||||
@@ -173,6 +180,7 @@ async function handler(
|
||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
|
||||
// 普通响应没有预解析字节上限;请求体 limit 不能用来证明上游 JSON 响应也受同样约束。
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
|
||||
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
|
||||
@@ -190,6 +198,7 @@ async function handler(
|
||||
);
|
||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
|
||||
// 按字段存在处理,不仅限定 auth 路径/成功状态;原始流透传分支不进入此令牌移除逻辑。
|
||||
if (body?.access_token) {
|
||||
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
|
||||
delete body.access_token;
|
||||
@@ -197,6 +206,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));
|
||||
}
|
||||
// 401 不在这里主动清 Cookie;错误响应保持状态,客户端再决定登录/重试流程。
|
||||
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
|
||||
if (path === "auth/logout" && upstream.ok)
|
||||
responseHeaders.set(
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function GET(
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const challenges = req.nextUrl.searchParams.getAll("challenge");
|
||||
// 这里只验证 challenge 字符表示,不验证 verifier/登录 state 或交换身份;额外查询参数也不会透传上游。
|
||||
const challenge = challenges[0];
|
||||
if (
|
||||
!["google", "slack"].includes(provider) ||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
import { Suspense } from "react";
|
||||
import GraphPage from "@/components/workspace/graph-page";
|
||||
|
||||
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
|
||||
/** params按当前Next版本await;Suspense为子树渲染时的挂起提供占位,不在此发模型请求。
|
||||
* params 的 await 在此边界外;GraphPage 在 effect 中加载模型时的进度/错误由自身状态呈现。 */
|
||||
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
|
||||
const { workspace, board } = await params;
|
||||
return (
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function DashboardSidebar() {
|
||||
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
|
||||
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
|
||||
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
|
||||
// 目录未解析时可暂用路由 ref;这里的字符串回退不是“空间存在/有权限”的证明,后续服务请求仍需验证。
|
||||
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
|
||||
|
||||
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
|
||||
@@ -68,6 +69,7 @@ export default function DashboardSidebar() {
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
// 只切换加载状态,不清空之前 profile/workspaces/error;成功读取也不会在这里自动清除旧 error。
|
||||
setStatus("loading");
|
||||
loadWorkspaceHome(api, controller.signal)
|
||||
.then(({ profile, workspaces }) => {
|
||||
@@ -89,6 +91,7 @@ export default function DashboardSidebar() {
|
||||
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
|
||||
useEffect(() => {
|
||||
if (!workspace || status !== "ready") return;
|
||||
// listVersion 只保护目录结果采用,不是网络取消或账号代次;创建成功时也递增它,防较旧列表覆盖新项。
|
||||
const version = ++listVersion.current;
|
||||
let active = true;
|
||||
setBoards((list) => list.filter((board) => board.workspace_id === workspace));
|
||||
@@ -120,6 +123,7 @@ export default function DashboardSidebar() {
|
||||
setCreating(true);
|
||||
setError("");
|
||||
try {
|
||||
// 捕获触发本次创建的 workspace/demo;await 后不另核对挂载/账户,宿主生命周期应避免旧操作采用到新界面。
|
||||
const board = demo
|
||||
? { id: crypto.randomUUID(), workspace_id: workspace, title: DEFAULT_BOARD_TITLE, kind: "board" as const, version: 1 }
|
||||
: await createBoard(api, DEFAULT_BOARD_TITLE, workspace, (created) => {
|
||||
@@ -128,6 +132,7 @@ export default function DashboardSidebar() {
|
||||
});
|
||||
listVersion.current += 1;
|
||||
setBoards((list) => [board, ...list]);
|
||||
// demo 已先更新 React 列表,随后 Storage 写失败也不回滚该列表;错误仅报告在侧栏。
|
||||
if (demo) localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify([board, ...readDemoBoards(workspace)]));
|
||||
setQuery("");
|
||||
router.push(boardHref(currentWorkspace?.slug || board.workspace_id, board.slug || board.id, demo));
|
||||
@@ -158,6 +163,7 @@ export default function DashboardSidebar() {
|
||||
}
|
||||
|
||||
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
|
||||
// 此函数无独立 busy/ref 互斥;多次点击仍可调用。退休身份成功但上游登出失败时也不会复活旧 runtime。
|
||||
async function logout() {
|
||||
try {
|
||||
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
|
||||
@@ -177,6 +183,7 @@ export default function DashboardSidebar() {
|
||||
|
||||
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
|
||||
async function renameTitle(title: string) {
|
||||
// 优先使用 activeBoard.id;编辑按钮先约束路由/空间匹配,此函数本身不重新执行同一组匹配检查。
|
||||
const id = activeBoard?.id || params.board;
|
||||
if (!id || !workspace) return;
|
||||
try {
|
||||
@@ -184,6 +191,7 @@ export default function DashboardSidebar() {
|
||||
const list = readDemoBoards(workspace);
|
||||
localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board)));
|
||||
} else await renameBoard(api, workspace, id, title);
|
||||
// 目录更新按 ID 匹配,活动元数据更新额外比 workspace;此处不递增 listVersion,旧目录读取仍可能随后到达。
|
||||
setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board));
|
||||
setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board);
|
||||
} catch (error) { throw new Error(errorText(error)); }
|
||||
@@ -196,6 +204,7 @@ export default function DashboardSidebar() {
|
||||
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间";
|
||||
|
||||
// 折叠只改变显示和键盘可达性,侧栏仍挂载,账户/目录状态和 effect 不因收起而销毁。
|
||||
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ActiveBoardProvider } from "@/components/workspace/active-board";
|
||||
import DashboardSidebar from "./dashboard";
|
||||
import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
|
||||
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。
|
||||
* 该边界响应侧栏渲染期间的 suspend,不自动等待侧栏在 effect 中发起的普通请求。 */
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ActiveBoardProvider>
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
|
||||
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。
|
||||
* children 作为插槽传入客户端宿主,不会因此把所有子页面模块变成客户端代码;宿主的释放逻辑在自身模块。 */
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" data-scroll-behavior="smooth">
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function Login() {
|
||||
verify = hash.get("verify_email"),
|
||||
failure = hash.get("error");
|
||||
if (!code && !verify && !failure) return;
|
||||
// 不只删除认证片段,也删除原 query;当前页面没有保留/恢复任意 returnTo 的流程。
|
||||
history.replaceState(null, "", location.pathname);
|
||||
if (failure) {
|
||||
setError("第三方登录未完成,请重新尝试。");
|
||||
@@ -68,6 +69,7 @@ export default function Login() {
|
||||
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
// busy 是显示状态,不是函数入口互斥锁;按钮禁用限制常规点击,submit 本身不检查已在执行。
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
@@ -76,6 +78,7 @@ export default function Login() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
// 使用触发本次提交时 render 闭包里的 register;等待期间切换模式不会改变这次请求的后续分支。
|
||||
if (register)
|
||||
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
|
||||
else router.push("/dashboard");
|
||||
@@ -98,6 +101,7 @@ export default function Login() {
|
||||
setError("无法启动登录,请重新尝试。");
|
||||
}
|
||||
}
|
||||
// submit/oauth 同样没有卸载取消或结果代次校验;回调链结束前可继续调用 router/setState。
|
||||
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
|
||||
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
|
||||
return (
|
||||
|
||||
@@ -21,12 +21,14 @@ export default async function Home() {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
|
||||
// 先保存 HTTP 成功判定;随后 cancel 若抛错,catch 不会把 authenticated 重置为 false。
|
||||
authenticated = response.ok;
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// An unverified session must not hide the public landing page.
|
||||
}
|
||||
}
|
||||
// redirect 放在上方 try/catch 外,避免把框架用于导航的控制流异常当作 profile 请求失败吞掉。
|
||||
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
|
||||
if (authenticated) redirect("/dashboard");
|
||||
return <LandingPage />;
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 }) {
|
||||
// 配置只用初次挂载的 node 初始化,不随外部 data.ai 自动重置;切换目标时应由宿主重建/隔离面板。
|
||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||
const [model, setModel] = useState(node.data.ai?.modelId || "");
|
||||
const [prompt, setPrompt] = useState(node.data.ai?.prompt || "");
|
||||
@@ -24,6 +25,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
|
||||
const image = ["image", "plain-image"].includes(node.data.kind);
|
||||
const 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 是 React 状态,按钮禁用在重渲染后生效,不是函数入口的同步互斥标记。
|
||||
// starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。
|
||||
const busy = starting || step?.status === "queued" || step?.status === "running";
|
||||
// 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。
|
||||
@@ -39,6 +41,7 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
|
||||
return () => controller.abort();
|
||||
}, [workspace, boardId, image, ai.live]);
|
||||
/** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */
|
||||
// 每次保存都显式 parameters:{},不会保留节点先前的自定义参数。
|
||||
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
|
||||
/** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */
|
||||
const persist = () => { if (model && ai.live) save(config()); };
|
||||
|
||||
@@ -21,11 +21,14 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest
|
||||
if (!request) return;
|
||||
const focus = document.activeElement as HTMLElement | null;
|
||||
ref.current?.querySelector<HTMLButtonElement>("button[data-choice]")?.focus();
|
||||
// outside 不阻止事件,关闭后原指针仍可交给被点击的画布/控件;这是非模态浮层,不提供焦点圈定。
|
||||
const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); };
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } };
|
||||
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
|
||||
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
|
||||
}, [request, close]);
|
||||
// choose 不在本组件内调用 close;异步创建、异常报告和关闭时机由宿主回调负责。
|
||||
// close 引用或 request 变化会重装监听并重新捕获焦点,不只在空→非空时执行。
|
||||
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
|
||||
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
@@ -59,6 +59,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
||||
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
|
||||
// marker 命名未加入 viewId/实例 ID,且字符替换可能碰撞;调用方须维持当前 SVG 文档内标识可用。
|
||||
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 || [];
|
||||
@@ -76,6 +77,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
||||
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
|
||||
const lease = editing.begin(props.id); if (lease === false) return;
|
||||
// begin 已执行后才安装监听/取得捕获;后续动作实现应不抛错,否则这里没有总括 try/finally 回滚。
|
||||
const controller = new AbortController();
|
||||
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
||||
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
|
||||
@@ -92,6 +94,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
if (!ownsPointerCapture(current,event)) return;
|
||||
if(!current)return;
|
||||
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
// 读的是最新 render 的 path,不是 pointerdown 快照;需要父层把 preview 更新投影回来以便后续事件继续编辑。
|
||||
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
||||
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
|
||||
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
||||
@@ -119,6 +122,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
|
||||
// 提交/取消动作先于 release,且未用 finally 包裹;动作抛错可能跳过捕获释放,不能称无条件清理。
|
||||
if (current.lease) current.lease.commit(); else editing.finish?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
@@ -132,6 +136,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
// 工具栏/键盘 patch 不是拖动 lease 流程,是否形成一次 history 单元仍由 Provider 决定。
|
||||
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
|
||||
pointerHandlers.current = { move, stop, cancel };
|
||||
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
|
||||
@@ -147,6 +152,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}
|
||||
}} />;
|
||||
// selectedKnot 不随外部 knots 删除自动重置;工具栏显示模式可退到最后一点,删除回调仍优先使用旧选中 ID。
|
||||
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
|
||||
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
|
||||
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
|
||||
@@ -178,6 +184,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
|
||||
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
|
||||
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
||||
// 本临时线仍使用兼容端点算法,与上方已建连线的 __headlessRecord 原生坐标分流不同。
|
||||
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 } };
|
||||
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setResult({ name, paths: peekIcon(name) });
|
||||
// 清理通过订阅 signal 取消,但 then 没有额外 active/代次检查;已兑现 Promise 的回调仍可能运行。
|
||||
// result.name 只能隔离不同名称,同名重试之间不靠此字段区分代次。
|
||||
loadIcon(name, controller.signal).then((paths) => setResult({ name, paths }), (error) => {
|
||||
if (error.name !== "AbortError") setResult({ name, error: true });
|
||||
});
|
||||
@@ -27,8 +29,10 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
|
||||
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
|
||||
const paths = result.name === name ? result.paths : undefined;
|
||||
const failed = result.name === name && result.error;
|
||||
// retry 不清浏览器 HTTP 缓存;同名已有成功分片时,loadIcon 仍可能直接复用共享缓存。
|
||||
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
|
||||
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
|
||||
// props 在默认 SVG 属性之后展开,可覆盖 viewBox 等;data-icon-* 和最终 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"}
|
||||
|
||||
@@ -11,7 +11,9 @@ export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded
|
||||
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
|
||||
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
|
||||
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
|
||||
// onLoad 只携带元素 id,不附带 src/资源代次;迟到加载能否清掉当前预览需由 Provider 的 loaded 实现判定。
|
||||
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
|
||||
// 没有 onError 状态或自动重试;底图失败时若上层仍保留 local,覆盖图可继续显示。
|
||||
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
|
||||
if (!local || local === src) return remote;
|
||||
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useCanvasOverlay(targetId:string,placement:string){
|
||||
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
||||
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
|
||||
useViewport();
|
||||
// 此处 getSnapshot 只读取当前 render;不单独订阅元素代次,外层须在元素变化时让节点重新渲染。
|
||||
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;
|
||||
@@ -26,10 +27,12 @@ export function useCanvasOverlay(targetId:string,placement:string){
|
||||
// 注册返回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与首帧测量补充尺寸/挂载后的变化。
|
||||
// 已排队的 blur 微任务无法取消,清理后仍会测量;索引租约的 token 检查使旧 update 不再发布。
|
||||
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
|
||||
// 清除本绑定拥有的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]);
|
||||
// ResizeObserver 只观察尺寸,普通页面滚动/位置动画不一定触发它;边界还依赖视口重渲染与布局提交刷新。
|
||||
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
|
||||
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImage
|
||||
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
|
||||
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
|
||||
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
|
||||
// 模块级初始化一次,供多个宿主复用;preview 选择同一组件不代表自动只读,交互权限仍由外层上下文限制。
|
||||
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
|
||||
|
||||
@@ -29,10 +29,13 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
|
||||
element?.setSelectionRange(element.value.length, element.value.length);
|
||||
}, [editing]);
|
||||
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
|
||||
// begin 没有成功返回值约定;即使作用域包装静默拒绝动作,本地 editing 仍被设 true,文档修改仍由动作护栏判定。
|
||||
function begin() { actions.begin(id); setEditing(true); }
|
||||
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
|
||||
const active = editing && selected;
|
||||
const label = plain ? "纯文本" : data.title;
|
||||
// 本组件未监听 compositionstart/end;正文变化直接转交 edit,输入法合并与 history 分组由 Provider 决定。
|
||||
// textarea 的 Escape 处理也不检查 isComposing,仅用于退出输入态。
|
||||
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
|
||||
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
|
||||
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
|
||||
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
|
||||
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
/* z-index 只在相关层叠上下文中排序;Portal、transform/zoom 或原生 top layer 可改变跨容器的叠放关系。 */
|
||||
/* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */
|
||||
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
|
||||
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
|
||||
@@ -134,6 +135,7 @@
|
||||
/* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */
|
||||
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
|
||||
.membershipNotice:empty { display: none; }
|
||||
/* 以下后代选择器依赖实际 DOM 祖先;移到 body Portal 的浮层不会仅因 React 上下文关系命中 .handMode。 */
|
||||
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
|
||||
.handMode .plainText { pointer-events: none; }
|
||||
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
||||
@@ -150,6 +152,7 @@
|
||||
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
|
||||
/* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */
|
||||
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
|
||||
/* 这里的10px属于局部 CSS 坐标,外层画布/viewScale 缩放后不保证仍为屏幕10px。 */
|
||||
/* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */
|
||||
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
|
||||
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
|
||||
@@ -217,6 +220,7 @@
|
||||
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
||||
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
|
||||
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
||||
/* 覆盖层能拦截其区域的指针,不自动使背后 DOM inert 或禁用全部键盘命令;准入仍须由事件/命令层处理。 */
|
||||
/* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */
|
||||
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
|
||||
/* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */
|
||||
|
||||
@@ -7,5 +7,6 @@ import styles from "./canvas.module.css";
|
||||
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
|
||||
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
|
||||
export function ContentHandles({ selected }: { selected: boolean }) {
|
||||
// selected 只设置 isConnectableStart/aria-hidden;未显式设置结束端能力或 tabIndex,仍须遵守外层 ReactFlow 配置。
|
||||
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function DraftRecovery({ restore, discard, canRestore = true, exportDraft
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
|
||||
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
||||
// 点击按钮只同步调用宿主,不自动 close,也没有 busy/重复点击互斥;宿主完成选择后负责卸载对话框。
|
||||
// 无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()}>
|
||||
|
||||
@@ -9,5 +9,6 @@ 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>() };
|
||||
// 默认 active Set 与动作对象是共享引用,未冻结;消费方只能读取,不应通过修改默认对象来发布状态。
|
||||
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
|
||||
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
||||
|
||||
@@ -12,6 +12,7 @@ import styles from "./canvas.module.css";
|
||||
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
||||
const a = data.appearance || {}, animation = data.animation || {};
|
||||
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
|
||||
// 合并基于本次 render 捕获的 data;change 必须由外层正确采用,不能把旧回调当成读取最新文档的入口。
|
||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||
@@ -20,6 +21,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count
|
||||
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
|
||||
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
|
||||
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
|
||||
// 每个控件即时调用回调,不负责 history 分组或持久化;数值范围检查也不要求步长整数倍。
|
||||
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
|
||||
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
|
||||
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
||||
|
||||
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
|
||||
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略。
|
||||
* 是否识别精确交互由 isCurrent 的实现保证;这里只冻结外壳,不复制 actions 或吞掉检查/动作异常。 */
|
||||
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
||||
return Object.freeze({
|
||||
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
|
||||
|
||||
@@ -20,6 +20,7 @@ export function MembershipBadge({ id, frameId, selected }: { id: string; frameId
|
||||
const name = target ? names.get(target) : undefined;
|
||||
if (!drop && !selected) return null;
|
||||
// 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。
|
||||
// 提示依赖 names 完整:目标有 ID 却缺名称时也会落入下面的移出/空提示分支,不证明真实归属已改变。
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ const options = [
|
||||
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
|
||||
// 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。
|
||||
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
// 未知值只让视觉索引回退0,下面 radio 的 aria-checked/tabIndex 仍按原 value 比较,不会自动修复模型。
|
||||
const current = Math.max(0, options.findIndex((option) => option.value === value));
|
||||
// 键盘计算基于受控 value 而非实际焦点;change 应让宿主及时发布新值,连续按键才沿新索引前进。
|
||||
// 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。
|
||||
// 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。
|
||||
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||
|
||||
@@ -38,15 +38,19 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
|
||||
const cancelBlur = useRef(false);
|
||||
const mode = data.labelMode || "fixed";
|
||||
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
|
||||
// 这里只 trim/补默认名,maxLength 是输入控件限制;UTF-8 字节和数据合法性仍由命令/格式层负责。
|
||||
function saveTitle() {
|
||||
const name = title.trim() || "未命名分区";
|
||||
if (name !== data.title) editing.frame(id, { title: name });
|
||||
setTitle(name);
|
||||
}
|
||||
// title effect 无论是否正编辑都跟随外部值,外部改名可能替换未提交草稿;局部保存后也不等待远端 ACK。
|
||||
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
|
||||
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
|
||||
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
|
||||
// 折叠按钮只提交 presentation 候选,不直接隐藏/删除后代;实际尺寸恢复和投影由动作实现处理。
|
||||
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
|
||||
// 所有端口都用 source 类型配合外层连接模式;方向差异由 Start/End 能力开关表达。
|
||||
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
|
||||
return <>
|
||||
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
||||
|
||||
@@ -141,6 +141,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
|
||||
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。 */
|
||||
// 这里优先按 local node.id 搜索,未同时限定 owner;旧协议跨 board 重名时取首个匹配,不能当作全局唯一身份。
|
||||
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
||||
// 预览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); } };
|
||||
@@ -190,6 +191,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
|
||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
|
||||
// checkpoint 只保存起点,没有对应 token 或原子结束动作;取消能力取决于旧节点组件的回退处理。
|
||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
|
||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||
@@ -225,6 +227,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||
tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId);
|
||||
const child = document.nodes.at(-1)!;
|
||||
// 使用 child 的 owner 局部坐标,未再投回根场景;Promise 的异步拒绝也不被外围同步 catch 捕获。
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
setBranchRequest(null);
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||
@@ -297,6 +300,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
|
||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (dragSession.current !== "active") return;
|
||||
// 先恢复 idle 才 await 后续成员移动;第二次拖动的防护依赖 tree 的保护节点/队列,而非这个状态持续锁定。
|
||||
const origins = new Map(dragOrigins.current);
|
||||
dragSession.current = "idle"; dragOrigins.current.clear(); setDraggedIds([]); tree.setDragging(false);
|
||||
if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) {
|
||||
@@ -330,6 +334,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
}
|
||||
}
|
||||
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
|
||||
// Promise.all 任一拒绝不取消其它组,也不回滚已完成的 hop;失败队首还可能保持未决 Promise 等待重试。
|
||||
await Promise.all([...byOwner].map(async ([key, group]) => {
|
||||
let source = key.split("/")[0];
|
||||
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
||||
@@ -392,6 +397,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
|
||||
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
|
||||
}
|
||||
// 文件选择完成后才消费保存的分支意图;不带源节点代次,实际创建前只按当前显示 ID 查 owner。
|
||||
const request = pendingImage.current; pendingImage.current = null;
|
||||
const kind = imageKind.current;
|
||||
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
|
||||
@@ -417,6 +423,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
|
||||
setImageUploading(true);
|
||||
try {
|
||||
// 演示 FileReader/Image 没有卸载取消信号或 await 后身份检查;回调仍使用本次调用捕获的创建函数。
|
||||
const src = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
@@ -447,6 +454,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
try {
|
||||
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
||||
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
||||
// await 前检查的是本次 render 的根;读完不重新核对子图关系/画布身份,tree.dispatch 准入只能覆盖其自身护栏。
|
||||
const document = decodeCanvas(await file.text());
|
||||
if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。");
|
||||
dispatch({ type: "replace", document });
|
||||
@@ -460,6 +468,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
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; }
|
||||
// 多 board bundle 仅导出文档 JSON;importProject 仍只接收普通单文档,并不是这个 bundle 的对称恢复器。
|
||||
const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2);
|
||||
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
@@ -489,10 +498,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
return () => window.removeEventListener("keydown", keydown);
|
||||
}, []);
|
||||
|
||||
// 上传失败行的“取消”只尝试移除占位节点,未直接 uploads.remove;任务/文件/预览寿命仍由上传 Hook 和卸载处理。
|
||||
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
|
||||
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
|
||||
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
|
||||
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
|
||||
// 迁移准入检查 UI 文件读取与上传;真正排空 writer/本地恢复由 prepareMigration 再检查。
|
||||
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
|
||||
return (
|
||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||
@@ -505,6 +516,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
|
||||
onBeforeDelete={async ({ nodes: removing }) => {
|
||||
if (tree.busy || tree.transactionBusy) return false;
|
||||
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
|
||||
// 删除流程使用本次回调捕获的 tree 投影;await 后通过内存表补查,但没有全程冻结选择和历史的统一事务。
|
||||
const linked = removing.filter((node) => node.data.subgraph);
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
const referenced = removing.filter((node) => {
|
||||
|
||||
@@ -19,11 +19,13 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
|
||||
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
|
||||
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
|
||||
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
|
||||
// getSnapshot 是本次 render 的读取,不建立订阅;任务状态更新是否触发重渲染由父层负责。
|
||||
const saved = object(element.data.ai), root = engine.getSnapshot();
|
||||
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
||||
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
|
||||
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
|
||||
const [propagate, setPropagate] = useState(saved.propagate === true);
|
||||
// 输入 state 仅从初次 saved 初始化;同一挂载内外部 ai 配置变化不会自动覆盖本地草稿。
|
||||
const image = ['image','plain-image'].includes(element.componentId);
|
||||
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
|
||||
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
||||
@@ -60,6 +62,8 @@ export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: Pr
|
||||
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
||||
// 状态文案先展示未解决取消/采用,再展示拒绝、已采用、等待受理与任务进度,防止把请求排队说成已执行。
|
||||
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
||||
// 保存配置与 startRun 是两个同步命令,没有合成一笔事务;配置成功而启动拒绝时,配置仍已修改。
|
||||
// 命令接受也不等于服务端已受理或已持久确认,后续状态以 ledger/run 为准。
|
||||
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
|
||||
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
|
||||
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
|
||||
|
||||
@@ -149,8 +149,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
||||
// current 是最近一次 React render 的快照,frame 合并期间可落后于 engine;需要强身份保证的入口仍须实时查询。
|
||||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||||
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
||||
// 这里只呈现 rejected/disabled,不捕获同步异常,也不在成功时自动清除旧错误文案。
|
||||
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)));
|
||||
}, []);
|
||||
@@ -204,6 +206,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
|
||||
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
|
||||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||||
// 入参 fontSize 未进入这个 Geometry patch;字体大小修改应走单独 data/style 命令,而不是几何租约。
|
||||
report(engine.sessions.update(viewId, target(id), patch));
|
||||
}
|
||||
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
||||
@@ -217,6 +220,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||||
/** 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 } } }); };
|
||||
// 返回分配的 ID 不代表创建成功:无 tx 时这里只 report 拒绝,调用方若要依赖结果须检查事务状态。
|
||||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||||
return id;
|
||||
}
|
||||
@@ -229,6 +233,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||
}
|
||||
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
||||
// 资源键只反映当前投影的 nodeId/assetId,身份和 URL 可用性由 resourceView/engine 重新核验。
|
||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
||||
useEffect(() => {
|
||||
@@ -245,6 +250,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||
// “已同步”文案依据这些 ledger/冲突计数,不是所有资源解码、所有后台任务或远端生成全部完成的证明。
|
||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
||||
useEffect(() => {
|
||||
@@ -286,6 +292,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
// 单选时才启用图片适配/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;
|
||||
// canFitImage 在显示上因任意视图的 gesture 都禁用;真正命令能否执行仍由 engine.fitImage 再判断。
|
||||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||||
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
|
||||
// Runtime-only publications must not invalidate every card's editing context.
|
||||
@@ -301,6 +308,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
||||
// data 命令收到的是这次传入的 textStyle 对象;本层不读取并深合并旧样式,合并语义由 editor 定义。
|
||||
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'); },
|
||||
@@ -318,6 +326,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||||
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
||||
// owner 的坐标 transform 在 begin 时捕获;token 护栏只核手势身份,归属变化是否合法由 sessions 校验。
|
||||
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') });
|
||||
},
|
||||
@@ -338,12 +347,14 @@ 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]);
|
||||
// rendererCommands 直接委托 editor;它不是额外按 viewId/渲染目标绑定的权限层,扩展 renderer 必须遵守调用契约。
|
||||
// 本组件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,不含文件字节。
|
||||
// 复制完成回调只比较 engine/viewId,未另设卸载信号;文件暂存、复制和导出均不因关闭面板而自动回滚。
|
||||
// 文件选择调用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); } }}
|
||||
@@ -369,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
|
||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||
onNodeDragStart={(event, node, nodes) => {
|
||||
// 起始位置表借用投影 position 引用;后续 preview 必须保持不可变更新,不能原地篡改这些起点。
|
||||
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); }); });
|
||||
@@ -419,9 +431,11 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
||||
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
||||
// 初始视口只捕获一次;同一挂载内 props.initialViewport 变化不会重新定位,切项目应由宿主正确重建/切会话。
|
||||
const [handoffViewport]=useState(initialViewport);
|
||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
||||
// attach/viewport 在 effect 中同步执行,未捕获异常;attached 是显示门闩,不负责服务端加载或持久化就绪。
|
||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,17 @@ export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectE
|
||||
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
|
||||
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
|
||||
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
||||
// scope 只比较 engine/canvas,不带挂载代次;同一目标卸载后的已兑现回调并非由此 ref 自动取消。
|
||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||
/** 统一承接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)); }); };
|
||||
// control 接收已经开始的 Promise:同步调用抛错不在它的 catch 内;这里也不阻止多次点任务按钮。
|
||||
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
|
||||
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
|
||||
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
|
||||
// 64KiB 只是后缀为 .json 时的格式探测;真正解析、校验、文件持久保存和逐步加入由 runtime 负责。
|
||||
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
|
||||
// “重试源文件校验/保存”调用 engine.retry,作用于项目待重试工作,不是只重试当前展示的 job。
|
||||
// 任务按钮按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>
|
||||
|
||||
@@ -66,6 +66,7 @@ function PlanStructureReview({plan}:{plan:ConflictPlan}){
|
||||
* 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 是 React 状态护栏,不是同步锁;真正的会话互斥与持久屏障仍由 engine 校验。
|
||||
// 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,不再认领为当前面板。
|
||||
@@ -75,13 +76,16 @@ export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngi
|
||||
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
|
||||
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
|
||||
},[engine]);
|
||||
// cancel/apply 的 await 之后不另核对 live 或 engine 代次;宿主应按 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);}}
|
||||
// plan 是会话生成的本地预览,不等于已发布/已持久化;只有 resolve/apply 的结果决定是否成功关闭。
|
||||
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
|
||||
const canonical=session?.canonical.document;
|
||||
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
|
||||
// records 是准备完成时的一次操作清单;面板不逐帧订阅 ledger,恢复屏障负责保持会话前提。
|
||||
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
|
||||
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
|
||||
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
|
||||
|
||||
@@ -14,6 +14,7 @@ import { fault } from "@/lib/canvas-core/values";
|
||||
import { errorText } from "@/lib/api";
|
||||
|
||||
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
|
||||
// failed 不随 src 变化自动重置;宿主以资源 ID 作 key 隔离不同候选的预览实例。
|
||||
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
|
||||
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
|
||||
type Candidate = { id: Id; title: string };
|
||||
@@ -25,6 +26,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
||||
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
|
||||
const dismiss=()=>{lifecycle.current?.abort();close();};
|
||||
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
|
||||
// target 只在首次挂载捕获;canvasId/engine 更换时不能指望它自动变成新目标,宿主应重建面板。
|
||||
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
|
||||
// 记录当前账户代次与工作区,收集已打开/正在打开的其它项目;不扫描服务端全部画布。
|
||||
useEffect(()=>{
|
||||
@@ -48,6 +50,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
||||
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
|
||||
}
|
||||
}
|
||||
// 候选只在此 effect 扫描一次,不订阅其它项目后续新增/移除的资源;真正插入仍需重新授权校验。
|
||||
setItems([...candidates.values()]);setLoading(false);
|
||||
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
|
||||
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
|
||||
@@ -69,6 +72,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
||||
},{signal});
|
||||
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
|
||||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
// 这两个宿主回调仍在 try 内;它们抛错会显示失败,但已经提交的创建事务不会回滚。
|
||||
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);}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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)
|
||||
&& (category === "all" || (category === "common" ? icon.common : category === "other" ? !icon.categories.length : icon.categories.includes(category)))
|
||||
|
||||
@@ -40,6 +40,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
const rows = Math.ceil(matches.length / columns);
|
||||
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
|
||||
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
|
||||
// 虚拟化只限制挂载按钮,matches 仍保留完整同步筛选结果;不是分批解析/下载目录。
|
||||
const visible = matches.slice(firstRow * columns, lastRow * columns);
|
||||
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
|
||||
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
|
||||
@@ -52,8 +53,10 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height;
|
||||
setScrollTop(grid.current.scrollTop);
|
||||
}
|
||||
// 帧未单独取消且不带筛选代次;执行时读最新 grid,筛选变化可能让同一 index 指向另一条结果。
|
||||
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
||||
}
|
||||
// Portal 直接使用 document.body,仅供客户端挂载;dialog 角色不自动提供焦点陷阱或背景 inert。
|
||||
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
|
||||
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
|
||||
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
|
||||
|
||||
@@ -37,6 +37,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
|
||||
const read=()=>readCanvasMigration(localStorage,scope);
|
||||
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
|
||||
// inspect 没有独立的在途 Promise 合并;storage/刷新可重复调用,跨页互斥由 Web Locks 与持久记录保证。
|
||||
async function inspect(){
|
||||
if(attempt.current||!current())return;
|
||||
setChecking(true);
|
||||
@@ -67,6 +68,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
refresh.current=inspect;void inspect();
|
||||
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
|
||||
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
|
||||
// storage 事件来自其它文档,本页 setItem 不靠此事件更新 UI;也未按 storageArea 区分其它 Storage 来源。
|
||||
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.
|
||||
@@ -78,6 +80,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
};
|
||||
window.addEventListener('storage',storage);
|
||||
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
|
||||
// release 在此是尽力异步清理,不等待也不安装 catch;它的拒绝不进入面板 error 状态。
|
||||
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
|
||||
@@ -85,6 +88,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
|
||||
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
|
||||
async function upgrade(){
|
||||
// attempt 是同步 ref,先于首次 await 设置,区别于仅用于渲染的 running 状态。
|
||||
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
|
||||
attempt.current=true;setRunning(true);setError('');
|
||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||
|
||||
@@ -20,11 +20,13 @@ function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height
|
||||
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
|
||||
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
|
||||
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
|
||||
// transaction 也直接返回拒绝,不执行传入事务函数;这不限制自定义 renderer 自行发起的外部副作用。
|
||||
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;
|
||||
// fitted 在调用 fitView 前置 true;期间依赖变化会取消迟到通知,但不会重新 fit。拒绝也没有本地 catch/重试。
|
||||
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
|
||||
return null;
|
||||
}
|
||||
@@ -38,12 +40,14 @@ export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,
|
||||
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
|
||||
const projection=useMemo(()=>{
|
||||
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
|
||||
// 每条边端点都追加描述,不按 handleId 去重;PreparingNode 使用临时索引 key,仅供预览定位。
|
||||
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
|
||||
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
|
||||
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
||||
},[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]);
|
||||
// onMoveEnd 同时记录用户移动;首次 fit 完成通知与用户移动并无额外竞争仲裁,由外层决定交接采用。
|
||||
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
|
||||
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
|
||||
<CanvasRendererCommandsContext.Provider value={commands}>
|
||||
|
||||
@@ -31,12 +31,14 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const { zoom } = useViewport();
|
||||
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
|
||||
// 像素比在 render 时读取,不单独监听其变化;zoom 订阅或其它重渲染才重新计算此启发式。
|
||||
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中心打开菜单,由动作层再处理合法性。
|
||||
// pointerStart 不按 pointerId 分组,属于点击位移辅助;键盘入口也不在这里检查 branchable,由动作实现兜底。
|
||||
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
|
||||
return (
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
|
||||
@@ -41,12 +41,14 @@ export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHook
|
||||
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
||||
// 只有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;
|
||||
// current 在动作触发时读取快照,不建立订阅;后续状态变化不会仅因这个检查而触发组件重渲染。
|
||||
// 无新版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。
|
||||
// 这里不捕获 gesture token;同视图同目标的新手势也能通过,精确手势身份须由创建者的租约保护。
|
||||
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
|
||||
// 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。
|
||||
const scoped:typeof editing={...editing,
|
||||
@@ -61,7 +63,7 @@ export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
||||
// 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结束新交互。
|
||||
// 结束手势要同时通过对象有效性与归属检查;这不是对任意迟到事件的完整 token 隔离。
|
||||
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
|
||||
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
|
||||
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
|
||||
|
||||
@@ -19,9 +19,11 @@ export function useNodeInternalsUpdate(id:string){
|
||||
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
|
||||
useEffect(()=>{
|
||||
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
|
||||
// batch 首次创建时捕获 update;同 store 后续使用者沿用该更新器,不在每次 effect 中替换。
|
||||
const owner=batch.acquire(id);lease.current=owner;
|
||||
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
|
||||
},[scope,update,id]);
|
||||
// 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。
|
||||
// 回调通过ref读取当前租约;旧调用者保存的同一回调,在本组件 id 改变后会转向新 id 的租约。
|
||||
// effect 尚未安装/已清理则无动作;它不把回调永久绑定到最初 id。
|
||||
return useCallback(()=>lease.current?.invalidate(),[]);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
|
||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
|
||||
// 每次控件变化立即派发;没有在本组件合并拖动取色等多次输入为一个 undo 项,也没有等待服务端确认。
|
||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||
// 显示默认值不写回 data;命令校验仍由 editing 实现,select 的类型断言不是运行时 schema 验证。
|
||||
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
|
||||
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
|
||||
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
|
||||
|
||||
@@ -17,11 +17,13 @@ export function NodeTitle({ id, title, selected,renderedTarget }: { id: string;
|
||||
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
|
||||
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
|
||||
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
|
||||
// rename 是同步动作入口,不等待远端 ACK;若实现抛错,下方标记复位与退出也不会自动执行。
|
||||
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;按键不继续冒泡到画布。
|
||||
// maxLength 是浏览器 UTF-16 长度限制;此 save 只 trim/去空/比旧值,不另做 UTF-8 字节校验。
|
||||
// 标题长度限制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(); } }} />;
|
||||
|
||||
@@ -14,6 +14,7 @@ export function PlainImageNode(props: NodeProps<CanvasNode>) {
|
||||
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
|
||||
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
|
||||
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
// 该估算只影响托管 URL 的 variant;不是独立下载任务,不会为普通外部图片生成预览资源。
|
||||
// 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>;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
export function createRetainedProjection(){
|
||||
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
|
||||
const tagged=new WeakMap<object,object>();
|
||||
// previous 强引用最近一批输入/输出;WeakMap 不等于整个实例完全不持有投影,空 ids 才清这份整批引用。
|
||||
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
|
||||
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
|
||||
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
|
||||
// 清 previous 不清 tagged;同一对象日后再次被保留,仍可能复用已有带标记副本。
|
||||
if(!ids.size){previous=undefined;return{nodes,edges};}
|
||||
// 数组引用及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[]};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CanvasNodeData } from "@/lib/canvas-elements";
|
||||
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
|
||||
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
|
||||
export function textAppearance(data: CanvasNodeData): CSSProperties {
|
||||
// 每次生成新的扁平 CSS 对象;不缓存节点引用,也不会写回默认值到 data。
|
||||
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,
|
||||
fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };
|
||||
|
||||
@@ -76,6 +76,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
||||
if (!editing.beginTransform) editing.begin(id);
|
||||
if (!node.selected) editing.select(id);
|
||||
// node 是 flow.getNode 返回的引用,不深克隆;“起点快照”依赖外层不可变更新约定,不能原地修改该节点。
|
||||
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
|
||||
event.preventDefault();event.stopPropagation();
|
||||
@@ -87,6 +88,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
if (!ownsPointerCapture(gesture.current,event)) return;
|
||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const { node, start, direction } = gesture.current!;
|
||||
// resizing 模式显式传 Shift 布尔值,未按 Shift 即解锁比例;普通角落传 undefined 沿用图片/图标默认比例规则。
|
||||
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);
|
||||
@@ -100,6 +102,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||||
const current = gesture.current; if (!current) return;
|
||||
gesture.current = null;
|
||||
// cancel/commit 调用与释放间没有 finally;Provider/lease 抛错时可能跳过 release,需要遵守动作异常契约。
|
||||
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
@@ -107,9 +110,11 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
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;
|
||||
// 当前手势引用已清空;若 commit 抛错,不会自动恢复引用或执行后面的 capture 释放。
|
||||
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
// 模式关闭/取消选中本身不调用 cancelGesture;实际手势结束还依赖 pointerup/cancel、失焦或卸载。
|
||||
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
|
||||
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
|
||||
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
|
||||
@@ -139,6 +144,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
||||
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);
|
||||
// 键盘按键没有 pointer capture,也不经过 move;preview/commit 同步连用,失败清理由租约实现承担。
|
||||
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
|
||||
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
|
||||
}} />;
|
||||
|
||||
@@ -17,6 +17,7 @@ const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,le
|
||||
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
|
||||
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
||||
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
|
||||
// 只读取公共投影信封;此组件本身没有 TransformableNode,也不安装任何编辑动作处理器。
|
||||
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>
|
||||
@@ -27,6 +28,7 @@ export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
||||
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
|
||||
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
|
||||
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
|
||||
// 采用端点派生的通用曲线,不重放未知模型的自定义 path/marker;保留数据与视觉完全还原是两件事。
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
const reference = useRef(tree); reference.current = tree;
|
||||
const wake = useRef<() => void>(() => {});
|
||||
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
|
||||
// 这些 ref/state 不因 workspace/root/live 改变而清空;宿主应按作用域重建 Hook 所在视图,避免沿用旧跟踪表。
|
||||
const cache = useRef(new Map<string, AIRun>());
|
||||
const edgeStates = useRef(new Map<string, Set<string>>());
|
||||
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
|
||||
@@ -45,6 +46,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
|
||||
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
|
||||
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
|
||||
// alive 只控制继续分页/发布等交接点;await 返回后仍可能先写共享 ref,不是每次赋值都有代次护栏。
|
||||
async function discover() {
|
||||
let cursor = ""; const seen = new Set<string>();
|
||||
do {
|
||||
@@ -77,6 +79,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
}
|
||||
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
|
||||
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
|
||||
// dirty 只比较该 run 原游标和状态;同游标/同状态下其它响应字段变化不一定立即发布给 React。
|
||||
if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true;
|
||||
cache.current.set(id, current);
|
||||
} while (current.has_more && alive);
|
||||
@@ -115,11 +118,13 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
|
||||
}
|
||||
/** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */
|
||||
// 此写请求没有轮询 effect 的 signal,也未检查 live;按键是否可用由调用方和服务端权限共同约束。
|
||||
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协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
|
||||
// run/apply 同样不由 effect 清理自动取消;卸载只停跟踪,不撤销已经发起的命令副作用。
|
||||
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);
|
||||
|
||||
@@ -21,10 +21,12 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
||||
if (alive.current) { setSync(next); backup(); }
|
||||
}) : null);
|
||||
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
|
||||
// writer 通知捕获创建时的 backup/key;latest 与 pausedRef 保持当前值,key/workspace/board 改变须重建 Hook 所在视图。
|
||||
function backup() {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const current = latest.current;
|
||||
// dirty 使用包含 viewport 的指纹,而 writer.needsSave 忽略 viewport;两者用途不同,不可互换解释。
|
||||
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
|
||||
} catch { /* Storage failure is surfaced by the effect below. */ }
|
||||
}
|
||||
@@ -35,6 +37,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
||||
try { localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(latest.current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(latest.current) !== writer.saved : false })); }
|
||||
catch { setSync({ status: "error", version: writer?.version || 1, error: "local_backup_failed" }); }
|
||||
}, 250);
|
||||
// 此处只入队,不 await/捕获 running 的拒绝;CanvasSync 的通知与回调需遵守它自身的异常约定。
|
||||
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
|
||||
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
|
||||
}, [document, writer, key, paused]);
|
||||
@@ -48,6 +51,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
||||
// Writer and key are stable for this board's mounted editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writer, key]);
|
||||
// 同步 localStorage 写入只保证调用返回时 API 未抛错;没有回读、版本日志或刷盘确认。
|
||||
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
|
||||
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
|
||||
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
||||
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
|
||||
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
|
||||
* 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');
|
||||
@@ -88,6 +88,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
function resolveRecovery(boardId: string, restore: boolean) {
|
||||
if(migrationBlocked())return;
|
||||
const draft = pendingRecovery.current.get(boardId);
|
||||
// update 没有成功返回值;若其它准入条件拒绝恢复,下面仍会移除候选/记录,宿主须先保证可恢复。
|
||||
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
||||
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
||||
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
||||
@@ -107,6 +108,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
if (writer) writer.adopt(value);
|
||||
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
|
||||
if (!mounted.current) return;
|
||||
// 显示的是最近通知的 writer 状态,不是遍历全部 writer 后算出的项目汇总;错误只在显式路径清除。
|
||||
setStatus(state.status); if (state.error) setError(state.error);
|
||||
}));
|
||||
}
|
||||
@@ -195,6 +197,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
/** 按需加载真实快照或演示本地草稿,装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");
|
||||
// ensure 自身不复用 loading/在途 Promise;并行调用同一缺失 board 可能重复读取。loading 只服务下方可见子图 effect。
|
||||
let document = current.current.get(boardId);
|
||||
if (!document) {
|
||||
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
|
||||
@@ -253,6 +256,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
if(migrationBlocked())return;
|
||||
for (const [boardId, document] of current.current) try {
|
||||
if (pendingRecovery.current.has(boardId)) continue;
|
||||
// canvasPayload 会过滤未上传占位及其边;此草稿记录不能保存原文件或保证刷新后恢复上传。
|
||||
const writer = writers.current.get(boardId);
|
||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
|
||||
} catch { setError("local_backup_failed"); }
|
||||
@@ -334,6 +338,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
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()];
|
||||
// 每项 install 都立即改变 writer 基线;后项校验/合并抛错时,先前 writer 不自动回滚,Map 可能还没 publish。
|
||||
for (const value of values) {
|
||||
const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document;
|
||||
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
||||
@@ -356,7 +361,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
return [...new Set(values.map((value) => value.board_id))];
|
||||
}
|
||||
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
|
||||
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
|
||||
* execute闭包复用同一body对象/key/version,但每次发送重新JSON编码;这些恢复信息只在内存,不保证刷新后继续。 */
|
||||
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
||||
if (retryOperation.current) throw new Error("canvas_busy");
|
||||
return exclusive(async () => {
|
||||
@@ -365,11 +370,13 @@ 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])) } : {}) };
|
||||
// body 只浅展开 payload,嵌套值仍共享引用;外部原地修改可能改变重试字节,不同于新版 FrozenRequest.body。
|
||||
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) });
|
||||
// accept 和历史/补读也在这个 try 内;采用中途失败不等于服务端命令未执行,只有下方分类决定是否保留重试。
|
||||
const changed = accept(result);
|
||||
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
|
||||
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
||||
@@ -464,6 +471,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// A definite rejection performed zero writes. A retry may prepare fresh
|
||||
// versions; ambiguous transport failures must retain the exact body/key.
|
||||
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
|
||||
// 此处把所有 ApiError<500 当确定拒绝,没有核对 terminal 回执身份;它是旧协议假设,不是新版证据判定。
|
||||
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
||||
showMembers();
|
||||
} finally {
|
||||
@@ -474,6 +482,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
/** 检查准入和重叠成员任务后入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"));
|
||||
// Promise 只有成功出队才 resolve,没有 reject/卸载取消;失败留队或页面离开后不能把它当已完成屏障。
|
||||
return new Promise<void>((resolve) => {
|
||||
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
||||
});
|
||||
@@ -498,6 +507,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
|
||||
function movePendingImage(source: string, target: string, id: string) {
|
||||
if(migrationBlocked())throw new Error("canvas_busy");
|
||||
// 这里仅检查迁移门闩,未复用 exclusive 的 paused/locked/待重试准入;调用者必须先满足更完整的交互条件。
|
||||
const from = current.current.get(source)!, to = current.current.get(target)!;
|
||||
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
|
||||
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
|
||||
@@ -553,6 +563,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
||||
}
|
||||
}
|
||||
// 分支中的多次 await 不持有跨全流程历史锁,也不重新比对 stack 顶项;宿主应串行发起历史动作。
|
||||
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
|
||||
if ("id" in entry) {
|
||||
if (isRedo) {
|
||||
|
||||
@@ -30,9 +30,11 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
||||
if (job.status === "uploading") return;
|
||||
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
|
||||
try {
|
||||
// Promise.all 不会因 ready 拒绝自动中止上传;失败 UI 可先出现,而底层请求仍可能完成。
|
||||
const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]);
|
||||
// 结果返回后核对挂载、Map内仍是同一个job且未取消,旧任务不能写到后来同ID的新任务。
|
||||
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
|
||||
// commit 是同步调用,不等待网络 ACK;抛错不会自动撤销它此前已发布的本地修改。
|
||||
job.asset = asset; onCommit.current(job, asset); job.status = "uploaded"; changed();
|
||||
} catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } }
|
||||
}
|
||||
@@ -42,11 +44,13 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
||||
let value: ImageUploadDraft;
|
||||
try { value = draft(localURL); } catch (error) { URL.revokeObjectURL(localURL); throw error; }
|
||||
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
|
||||
// 节点 ID 必须由草稿工厂保证唯一;同 ID set 会替换旧任务,但此处不会 abort/revoke 被替换的任务。
|
||||
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) => {
|
||||
// 这个 Image 解码没有接入 controller 的取消,也没有显式移除 onload/onerror;回调以 Map 身份拒绝已移除任务。
|
||||
const image = new window.Image(); image.onload = () => {
|
||||
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
|
||||
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
|
||||
@@ -60,6 +64,7 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
||||
void run(job);
|
||||
return job;
|
||||
}
|
||||
// 每次重试仍调用上传接口,不直接采用 job.asset;幂等依赖相同 key,ready 失败也不会自动回滚服务端资源。
|
||||
// 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); } };
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
|
||||
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
|
||||
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
|
||||
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;展开内容是否存在由组件决定。 */
|
||||
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;组件保留回答 DOM,用 hidden 属性切换可见性。 */
|
||||
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
|
||||
.faqItem { border-bottom: 1px solid var(--line); }
|
||||
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
|
||||
@@ -203,5 +203,6 @@
|
||||
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
|
||||
.noteTag { font-size: 7px; padding: 2px; }
|
||||
}
|
||||
/* 只关闭 transition,不撤销 hover 的 translate 或卡片固定 rotate;减少动态效果并不把示意图恢复成无变换布局。 */
|
||||
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
|
||||
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function LandingPage() {
|
||||
const [active, setActive] = useState(0);
|
||||
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
|
||||
const [faq, setFaq] = useState<number | null>(null);
|
||||
// FAQ 的 hidden 只切换可见性,问答节点仍保留在 DOM;页面没有请求型加载或异步清理任务。
|
||||
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
|
||||
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
|
||||
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
|
||||
|
||||
@@ -13,7 +13,8 @@ export const FeedbackContext = createContext(false);
|
||||
|
||||
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
|
||||
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
|
||||
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
|
||||
* children正常透传,本组件不订阅文档、队列或服务端状态。
|
||||
* visibleToasts 限制同时可见数量,不是业务事件队列容量;通知创建/撤销仍通过通知适配器调用。 */
|
||||
export function AppFeedback({ children }: { children: ReactNode }) {
|
||||
return <FeedbackContext.Provider value={true}>
|
||||
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ButtonProps
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
|
||||
// asChild 不把链接转换成原生按钮:disabled 属性本身不保证链接无法导航,调用方须按宿主处理行为。
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -40,7 +40,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
|
||||
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
|
||||
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
|
||||
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。
|
||||
* 剩余 props 只传 Content;当前调用没有把 Overlay 的属性入口一并暴露给 DialogContent。 */
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
/* SVG 同时约束宽高与 flex-basis,防止图标在文字/布局挤压下变形。 */
|
||||
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
|
||||
/* 悬停填底、键盘可见焦点描边、禁用态降低透明度并提示不可操作。 */
|
||||
/* hover 未排除 :disabled,禁用按钮仍可能命中悬停底色;真正禁用语义由原生 disabled 属性承担。 */
|
||||
.button:hover { background: #e9e7e3; }
|
||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
@@ -21,6 +21,7 @@ export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "chi
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
||||
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
|
||||
// 后置 ...props 可以覆盖 aria-label 等同名属性;label 是默认名称,最终 DOM 语义由调用者共同负责。
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const button = (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
|
||||
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
|
||||
.surface {
|
||||
/* 变量按 DOM 继承:渲染到 body Portal 的浮层不会仅因 React 父子关系继承此处尺寸。 */
|
||||
--toolbar-icon-size: 20px;
|
||||
--toolbar-control-width: 32px;
|
||||
--toolbar-control-height: 32px;
|
||||
|
||||
@@ -29,6 +29,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// busy 是本次 render 的状态值,限制后续渲染后的重复提交;不是同步 ref 互斥或持久操作幂等键。
|
||||
if (busy) return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
setBusy(true);
|
||||
@@ -38,6 +39,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
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) });
|
||||
// 宿主回调也在 try 内;若回调抛错,会显示失败,但已成功的服务端修改不会因此回滚。
|
||||
onProfile(updated);
|
||||
} else {
|
||||
const name = String(data.get("name")).trim();
|
||||
@@ -49,6 +51,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// 请求没有本地 AbortController/结果代次标记;受控 Dialog 阻止普通关闭,父级强制卸载仍由宿主管理。
|
||||
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
|
||||
|
||||
@@ -17,6 +17,7 @@ const ActiveBoardContext = createContext<{
|
||||
|
||||
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
|
||||
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
||||
// Provider 本身不监听路由/登出,也不推断哪条异步结果较新;发布、清空及身份核对由调用方负责。
|
||||
const [board, setBoard] = useState<ActiveBoard>(null);
|
||||
const value = useMemo(() => ({ board, setBoard }), [board]);
|
||||
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
||||
|
||||
@@ -13,6 +13,7 @@ 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>());
|
||||
// memo 按 boards 引用重算;宿主更新条目须提供新数组,不能只原地改标题/父链再期待重新派生。
|
||||
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
|
||||
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
|
||||
useEffect(() => {
|
||||
@@ -22,6 +23,7 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
|
||||
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
|
||||
function render(items: BoardBranch[], depth = 0) {
|
||||
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
|
||||
// 搜索中的切换只修改 closed 记录,当前仍强制展开;清掉搜索后才看到用户折叠记录的效果。
|
||||
const expanded = !!query.trim() || !closed.has(board.id);
|
||||
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
|
||||
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
|
||||
|
||||
@@ -23,6 +23,7 @@ const Renderers=createContext(defaultCanvasRenderers);
|
||||
export function useCanvasRenderers(){return useContext(Renderers);}
|
||||
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
|
||||
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
|
||||
// 惰性初始化只装配对象/工厂,不在 render 直接打开项目;实际账户绑定和持久化租约由后续调用取得。
|
||||
const [{host,renderers}] = useState(() => {
|
||||
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
|
||||
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
|
||||
@@ -51,6 +52,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
|
||||
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
|
||||
useEffect(() => {
|
||||
const generation = ++lifetime.current;
|
||||
// cleanup 不等待 close 完成,也不主动重试;异步失败经 host 的错误通道和此处 catch 报告。
|
||||
// Strict Mode replays effects synchronously; only a real detach retires the host.
|
||||
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
|
||||
}, [host]);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
||||
.title:disabled { cursor: default; }
|
||||
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
|
||||
/* max-width:100% 相对包含块,不是固定视口保证;浮动条宿主还需 min-width:0/宽度预算配合。 */
|
||||
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
||||
.input[readonly] { opacity: 0.65; }
|
||||
/* 错误框显示在标题下方,并以视口宽度限制最大宽度;长说明允许换行。 */
|
||||
|
||||
@@ -53,6 +53,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
cancelled.current = true;
|
||||
setError("");
|
||||
setMode("selected");
|
||||
// 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。
|
||||
requestAnimationFrame(() => button.current?.focus());
|
||||
}
|
||||
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
|
||||
@@ -70,6 +71,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
setMode("saving");
|
||||
setError("");
|
||||
try {
|
||||
// onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。
|
||||
await onSave(name);
|
||||
cancelled.current = true;
|
||||
setMode("idle");
|
||||
@@ -78,6 +80,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
setMode("editing");
|
||||
} finally { pending.current = false; }
|
||||
}
|
||||
// title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。
|
||||
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
|
||||
return (
|
||||
<div ref={shell} className={`${styles.shell} ${className}`}>
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
// 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。 */
|
||||
// 此错误显示只检查页面 active;它不像结果采用分支那样再核对 currentIdentity。
|
||||
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
||||
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
|
||||
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
||||
@@ -68,6 +69,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
function pump(){
|
||||
if(pumping)return pumping;
|
||||
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
||||
// finally 清空运行句柄,再对等待槽续泵;latest 只留最近预览,并非逐块持久操作队列。
|
||||
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
catch(error){reportBackground(error);}
|
||||
}
|
||||
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
|
||||
// 任一次泵拒绝会沿外层 catch 显示错误,不继续把完整结果冒充已经成功采用。
|
||||
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});
|
||||
@@ -101,12 +104,14 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
|
||||
// route 只包含两个路径参数,不包含 demo/retry/authEpoch;同路由这些值变化时由 effect 清状态和取消护栏处理。
|
||||
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 });
|
||||
// 这是当前页面的无条件清空,不删除 runtime 或目录记录,也不是按 boardId 比对的条件撤回。
|
||||
return () => setActiveBoard(null);
|
||||
}, [board, workspaceId, setActiveBoard]);
|
||||
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
import { useState } from "react";
|
||||
import styles from "./workspace.module.css";
|
||||
|
||||
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
|
||||
/** 只记住最近一次失败URL;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。
|
||||
* 换 URL 可以再试,若之后回到较早失败的 URL 也可能重试;不是永久失败地址集合。 */
|
||||
export function UserAvatar({ name, url, large = false }: {
|
||||
name: string;
|
||||
url?: string | null;
|
||||
large?: boolean;
|
||||
}) {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
// fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。
|
||||
const showImage = Boolean(url && url !== failedUrl);
|
||||
return (
|
||||
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
|
||||
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
/* 固定全视口画布置于底层;点阵背景与加载/错误状态由后续类分别承担。 */
|
||||
/* 主画布铺满整个视口,不按侧栏宽度缩窄;侧栏/浮动条通过更高层级叠在画布上方。 */
|
||||
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
|
||||
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
|
||||
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
|
||||
@@ -173,4 +174,5 @@
|
||||
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
|
||||
.directoryToggle:hover { background: #eae7e2; }
|
||||
.directoryToggle:focus-visible { outline: 2px solid #9b877a; outline-offset: -2px; }
|
||||
/* 此处旋转过渡没有单独的 reduced-motion 覆盖;上面的媒体查询只关闭指定的菜单/浮动条效果。 */
|
||||
.directoryToggle svg { transition: transform 120ms ease; }
|
||||
|
||||
+4
-1
@@ -27,9 +27,11 @@ export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
// fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。
|
||||
const res = await fetch("/api/foundry/" + path, {
|
||||
...options,
|
||||
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
|
||||
// FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。
|
||||
// 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
let body: unknown;
|
||||
@@ -49,6 +51,7 @@ export async function api<T>(
|
||||
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"));
|
||||
}
|
||||
// 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。
|
||||
return body as T;
|
||||
}
|
||||
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
|
||||
|
||||
@@ -10,6 +10,7 @@ type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
|
||||
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
|
||||
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
|
||||
// Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。
|
||||
const boards = new Map<string, Board>();
|
||||
let offset = 0;
|
||||
const seen = new Set<number>();
|
||||
@@ -28,6 +29,7 @@ export type BoardBranch = { board: Board; children: BoardBranch[] };
|
||||
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
|
||||
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
|
||||
export function boardDirectory(boards: Board[]): BoardBranch[] {
|
||||
// 重复 ID 采用最后一条数据、沿用首次插入顺序;分支节点是新对象,board 本身仍借用输入引用。
|
||||
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
|
||||
const roots: BoardBranch[] = [];
|
||||
for (const entry of entries.values()) {
|
||||
@@ -56,6 +58,7 @@ export function filterBoardDirectory(branches: BoardBranch[], query: string): Bo
|
||||
if (!term) return branches;
|
||||
return branches.flatMap((branch) => {
|
||||
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
|
||||
// 只重建需要裁剪 children 的祖先容器;直接命中的分支复用原对象,不深复制整棵树。
|
||||
const children = filterBoardDirectory(branch.children, term);
|
||||
return children.length ? [{ ...branch, children }] : [];
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Reg
|
||||
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
|
||||
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
|
||||
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
|
||||
// 键不含 rootId:独占迁移锁会暂时排斥同账户/工作区所有共享旧编辑租约,而非仅目标树。
|
||||
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
|
||||
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
|
||||
function ports(options:BrowserScope){
|
||||
@@ -30,6 +31,7 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
|
||||
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
|
||||
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
|
||||
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
|
||||
// signal 未传给 locks.request;取消在回调授予时检查,持锁后通过 abort 监听释放。
|
||||
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
|
||||
try{
|
||||
check(options);if(!lock)throw fault('migration_busy');
|
||||
@@ -56,6 +58,7 @@ export function createBrowserCanvasMigration(options:BrowserScope&{registry:Regi
|
||||
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
|
||||
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
|
||||
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
|
||||
// work 的 Promise 整体保持独占租约;拒绝同样交还锁,pending 记录不会随锁释放而自动删除。
|
||||
check(options);if(!lock)throw fault('migration_busy');return work();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
|
||||
/** 旧上传接口的素材响应形状;返回前检查 ID、URL 的源站/路径/查询和尺寸,不完整验证所有字段。 */
|
||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
|
||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
@@ -12,6 +12,7 @@ export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
* 调用方可提供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");
|
||||
// 文件大小是本地发送前检查;不在这里解码图片、计算内容摘要或确认 mime_type。
|
||||
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 });
|
||||
@@ -25,5 +26,6 @@ export async function uploadCanvasImage(request: Request, workspace: string, fil
|
||||
let url: URL;
|
||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
|
||||
// bytes/mime_type 未在此逐项核验,URL hash/用户名密码也未另设拒绝条件;不能视为新版资源权威证明。
|
||||
return asset;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${s
|
||||
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
|
||||
const segments = curveSegments(from, to, path);
|
||||
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
|
||||
// 只采内部参数 .025~.975;距离相同时保留先遇到的段/采样点,不会选择端点 t=0 或1。
|
||||
if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) {
|
||||
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
|
||||
if (d < distance) { distance = d; selected = index; t = step / 40; }
|
||||
@@ -33,6 +34,7 @@ export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["
|
||||
// 逐层插值求左右子曲线,anchor成为新结点,abc/bcd决定其两侧相对切线。
|
||||
const [a, b, c, d] = segments[selected];
|
||||
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
|
||||
// clone 隔离已有 knots;返回的 knot 同时就是 next.knots 中插入的对象,二者并非独立副本。
|
||||
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
|
||||
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
|
||||
// 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。
|
||||
|
||||
@@ -25,11 +25,13 @@ export abstract class CanvasElement {
|
||||
readonly src?: string;
|
||||
/** 构造业务创建参数;position在toNode输出时复制,尚未创建节点身份或渲染对象。 */
|
||||
constructor(position: XYPosition, text = "", src?: string) {
|
||||
// 构造器暂借调用者的位置对象;若转换前被外部修改,toNode 会采用修改后的坐标。
|
||||
this.position = position;
|
||||
this.text = text;
|
||||
this.src = src;
|
||||
}
|
||||
/** 每次转换都生成新UUID与独立position/data/style,字体默认随plain-text与卡片类型选择。 */
|
||||
// 同一个业务实例多次 toNode 也会生成不同节点身份,并非按实例 ID 幂等转换。
|
||||
toNode(): CanvasNode {
|
||||
return {
|
||||
id: crypto.randomUUID(), type: this.nodeType, position: { ...this.position },
|
||||
@@ -87,6 +89,7 @@ export class PlainImageElement extends CanvasElement {
|
||||
/** 封闭内置kind→构造器表,是旧元素工厂入口,不接受任意未知kind动态执行。 */
|
||||
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement, frame: FrameElement, "plain-image": PlainImageElement };
|
||||
/** 按kind读取静态能力,调用方需先保证kind属于内置枚举。 */
|
||||
// readonly 只约束静态属性重新赋值;能力对象仍是共享引用,调用方不能原地改动返回值。
|
||||
export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; }
|
||||
/** 创建指定内置类型的空内容节点,src仅作为创建参数传入;不自动上传文件或登记history。 */
|
||||
export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canva
|
||||
export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:LiveModelPreview)=>void}):Promise<ModelLoad>;onPreview?:(preview:LiveModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) {
|
||||
const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`;
|
||||
// slug/路由ref先解析成真实ID,画布kind、引用和工作区/账户信息必须匹配再选择服务作用域。
|
||||
// 两个读取共享取消信号,但任一拒绝不会自动取消另一个;这里不分配页面状态或编辑租约。
|
||||
const [board, profile] = await Promise.all([options.request<Board>(path, { signal: options.signal }), options.request<Profile>('profile', { signal: options.signal })]);
|
||||
options.signal?.throwIfAborted();
|
||||
if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference');
|
||||
@@ -24,6 +25,7 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
|
||||
let migration:Extract<ModelLoad,{status:'migration-required'}>|undefined;
|
||||
try {
|
||||
// 预览回调携带同次读取的board/profile,navigationGeneration由共享loader隔离导航结果。
|
||||
// 注入的 readModel 优先于 transport;入口本身不去重请求,也不保存或撤销已发出的预览。
|
||||
const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
// 新模型一旦ready立即走新版,不能因旧数据端点仍存在就继续让旧writer写它。
|
||||
@@ -39,5 +41,6 @@ export async function loadCanvasEntry(options: { request: CanvasRequest; workspa
|
||||
}
|
||||
// 旧请求显式协商v2快照;调用页面仍负责对当前导航/身份校验,不在这里安装页面状态。
|
||||
const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } }));
|
||||
// 此回退路径在 await 后没有再次 throwIfAborted;宿主仍须核对导航/身份后才能采用结果。
|
||||
return { mode: 'legacy' as const, board, profile, snapshot,...(migration?{migration}:{}) };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { nodeCenter, nodeSize } from "./canvas-geometry.ts";
|
||||
/** 非frame节点按中心点找包含它的分区,多个重叠候选选面积最小者;frame本身不成为另一frame成员。 */
|
||||
export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) {
|
||||
if (node.data.kind === "frame") return undefined;
|
||||
// 以未旋转布局矩形判包含,边界算内部;不是对旋转后的可见形状做精确命中。
|
||||
const center = nodeCenter(node);
|
||||
return nodes.filter((frame) => {
|
||||
if (frame.data.kind !== "frame") return false;
|
||||
@@ -44,8 +45,10 @@ export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[])
|
||||
export function applyFrameNodeChanges(changes: NodeChange<CanvasNode>[], previous: CanvasNode[]) {
|
||||
const nodes = applyNodeChanges(changes, previous);
|
||||
const before = new Map(previous.map((node) => [node.id, node]));
|
||||
// 仅带实际 position 值的变更计入显式移动;dragging 标记变化本身不触发成员平移。
|
||||
const positioned = new Set(changes.flatMap((change) => change.type === "position" && change.position ? [change.id] : []));
|
||||
const frames = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node]));
|
||||
// 单次 map 按原 frameId 计算直接父位移,不递归累积嵌套分区;合法旧数据约定 frame 不归另一 frame。
|
||||
return nodes.map((node) => {
|
||||
const frameId = node.data.frameId;
|
||||
if (!frameId) return node;
|
||||
|
||||
@@ -46,10 +46,12 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
|
||||
const rotation = node.data.rotation || 0;
|
||||
// 指针位移先消除节点旋转,才能区分拖动的是本地宽还是高。
|
||||
const local = rotateVector(delta, -rotation);
|
||||
// delta 是手势起点到当前的场景位移,屏幕缩放换算需由调用者提前完成。
|
||||
const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
|
||||
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
||||
const plain = node.data.kind === "plain-text";
|
||||
const icon = node.data.kind === "icon" || node.data.kind === "plain-image";
|
||||
// viewScale 只用于尺寸约束;此处不校验正有限值,0 会按 || 回退成1。
|
||||
const viewScale = node.data.viewScale || 1;
|
||||
// 约束随显示viewScale缩放;frame可更大,普通卡片/文字/图标最小尺寸各有不同。
|
||||
const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale;
|
||||
@@ -84,6 +86,7 @@ export type Connector = { point: XYPosition; normal: XYPosition };
|
||||
export function nodeConnector(node: CanvasNode, side: ConnectorSide): Connector {
|
||||
const { width, height } = nodeSize(node);
|
||||
const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } };
|
||||
// side 由联合类型限制,不在运行时回退未知端口;这里的法线假设为四个单位轴向量。
|
||||
const local = vectors[side];
|
||||
const angle = node.data.rotation || 0;
|
||||
const normal = rotateVector(local, angle);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createResourceLoader } from "./resource-loader.ts";
|
||||
export type IconPaths = [string, Record<string, string | number>][];
|
||||
/** Tabler统一分片,其它名字用稳定31进制散列落入96个Lucide分片;散列仅用于定位而非安全校验。 */
|
||||
export function iconAsset(name: string) {
|
||||
// 每步 >>>0 约束为无符号32位;名称大小写参与散列,不能擅自归一化后再查分片。
|
||||
let hash = 0;
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
const key = name.startsWith("Tabler") ? "tabler" : `lucide-${hash % 96}`;
|
||||
@@ -31,9 +32,11 @@ export function validateIconShard(value: unknown): Record<string, IconPaths> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 校验通过后原对象直接返回,不复制/冻结路径和属性;消费者必须把共享结果当只读数据。
|
||||
return value as Record<string, IconPaths>;
|
||||
}
|
||||
/** 单例分片加载器使用HTTP强缓存,响应失败或校验失败不写入成功缓存。 */
|
||||
/** 单例加载器以 force-cache 请求浏览器缓存,并在内存缓存校验后的分片;不保证服务端缓存期限。
|
||||
* 响应失败或校验失败不写入成功缓存;先整体读取 JSON,不限制下载字节数或分片图标总数。 */
|
||||
const resources = createResourceLoader(async (url, signal) => {
|
||||
const response = await fetch(url, { signal, cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("图标加载失败");
|
||||
|
||||
@@ -10,7 +10,9 @@ import { matchesRef, type Board } from "./workspace.ts";
|
||||
* 返回数据不直接更新UI,导航是否仍有效由调用方控制。 */
|
||||
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
|
||||
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
|
||||
// 两次请求同时启动;没有结果发布,也不会因一个失败主动取消另一个。
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]);
|
||||
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
|
||||
// decodeSnapshot 验证旧协议快照;此函数不另做 await 后取消检查或 canonical 迁移状态探测。
|
||||
return { board, snapshot: decodeSnapshot(snapshot) };
|
||||
}
|
||||
|
||||
@@ -85,13 +85,14 @@ export function createCanvasMigration(options:{
|
||||
isCurrentIdentity():boolean;createId():Id;lock<T>(work:()=>Promise<T>):Promise<T>;signal?:AbortSignal;
|
||||
}){
|
||||
const key=canvasMigrationStorageKey(options.scope);let active=false;
|
||||
/** 每个异步边界重查账户与取消状态,避免迟到结果删除另一身份正在使用的记录。 */
|
||||
/** 在协调流程标明的异步交接点重查账户与取消;注入 transport/drain/lock 也须遵守自身生命周期契约。 */
|
||||
const current=()=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal?.throwIfAborted();};
|
||||
/** 本实例读取也受当前身份护栏约束,原始readCanvasMigration仅负责数据格式。 */
|
||||
const read=()=>{current();return readCanvasMigration(options.storage,options.scope);};
|
||||
/** 迁移/回执查询始终按冻结root寻址,不按当前页面任意子分区重定向。 */
|
||||
const path=(root:Id)=>`workspaces/${encodeURIComponent(options.scope.workspaceId)}/materials/${encodeURIComponent(root)}/canvas/model`;
|
||||
/** 写入并回读完全相同的编码,失败不继续发请求;这是本地恢复前置条件,不是服务端成功证明。 */
|
||||
// 同步回读验证当前 Storage 可见值;不等于跨设备副本、数据库事务或磁盘刷写证明。
|
||||
function save(job:CanvasMigrationJob){
|
||||
current();const encoded=JSON.stringify(job);options.storage.setItem(key,encoded);
|
||||
if(options.storage.getItem(key)!==encoded)throw fault('migration_not_durable');
|
||||
@@ -123,6 +124,7 @@ export function createCanvasMigration(options:{
|
||||
sources.add(source);targets.add(target);
|
||||
}
|
||||
// 目标ID唯一且结构匹配,映射数量必须覆盖迁移后每个元素,不能漏一部分就切换编辑器。
|
||||
// 这里没有重新读旧快照逐项核对 sourceId;源清单真实性仍依赖服务端迁移协议和冻结版本。
|
||||
if(targets.size!==loaded.document.elements.size)throw fault('invalid_migration_receipt');
|
||||
}catch(error){current();return{status:'pending',job,error:(error as {code?:string}).code??'invalid_migration_receipt'};}
|
||||
// 只在整个成功回执通过后删除本地pending;删除也须回读确认,失败保留重试义务。
|
||||
@@ -143,6 +145,7 @@ export function createCanvasMigration(options:{
|
||||
// The current canonical receipt endpoint omits protocol_version on unknown.
|
||||
// This is permission to retry the exact idempotent migration, never zero-write proof.
|
||||
// 此协议允许unknown后重发同一幂等请求,绝不因此改requestId/body或宣称先前零写入。
|
||||
// 仅 query 分支转一次 POST;POST 仍未知则 accept 返回 pending,不在这里无限轮询。
|
||||
if(query&&response.status===404&&(body?.protocol_version===undefined||body.protocol_version===2)&&body?.request_id===job.id&&body.status==='unknown')return send(job,false);
|
||||
return accept(job,response);
|
||||
}
|
||||
@@ -165,6 +168,7 @@ export function createCanvasMigration(options:{
|
||||
if(previous.rootId!==rootId)throw fault('workspace_migration_pending');
|
||||
return send(previous,true); // Never flush old writers after an unknown identity change.
|
||||
}
|
||||
// drain 的范围由宿主保证;本协调器等待它完成,不自行枚举/停止旧 writer。
|
||||
await drain();current();
|
||||
const source=await loadFoundryModel(options.transport,options.scope.workspaceId,rootId,options.registry,options.signal);current();
|
||||
if(source.status==='ready')return{status:'already-migrated',rootId:source.document.projectId};
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim
|
||||
const abort = new AbortController();
|
||||
// 外部请求取消与内部超时任一生效都中止同一上游fetch,避免客户端已离开却继续传输。
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
|
||||
/** 三种时限共用内部取消源;只发送取消信号,实际唤醒等待依赖 request/reader 遵守取消契约。 */
|
||||
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
|
||||
// 初始计时器只约束拿响应头,取得Response后清掉,再给body设置独立总时限。
|
||||
let timer = setTimeout(timeout, limits.headersMs);
|
||||
@@ -38,7 +39,8 @@ export async function fetchCanvasResponse(url: string, options: RequestInit, lim
|
||||
};
|
||||
let idle: ReturnType<typeof setTimeout> | undefined;
|
||||
let finished = false;
|
||||
/** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。 */
|
||||
/** 标记结束并清除总/空闲计时与组合signal监听,后续迟到read不能再次enqueue或重复结束。
|
||||
* 这里只清理调度资源,不 releaseLock;reader 的取消在中断/下游 cancel 路径另行执行。 */
|
||||
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
|
||||
let interrupt: (reason: unknown) => void;
|
||||
/** 将组合signal的取消原因交给流start中安装的中断处理器。 */
|
||||
|
||||
@@ -38,7 +38,8 @@ const pathFor = (workspace: Id, canvas: Id) => `workspaces/${encodeURIComponent(
|
||||
|
||||
/** Credentials stay in the host/session. Every write additionally binds the expected account. */
|
||||
/** 默认将请求发送到同源代理,凭证留在宿主会话,不塞进请求描述或恢复镜像。
|
||||
* 发出前与收到后均检查当前身份,带 X-Expected-Account 供服务端复查账户;跨身份响应不能交给旧调用者。 */
|
||||
* 发出前与收到后均检查当前身份,带 X-Expected-Account 供服务端复查账户;跨身份响应不能交给旧调用者。
|
||||
* fetch 在创建时选定;其余 options 在请求时读取,宿主应维持此适配器的账户/路径绑定稳定。 */
|
||||
export function createFoundryTransport(options: { accountId: Id; isCurrentIdentity(): boolean; fetch?: typeof fetch; basePath?: string }): ServiceTransport {
|
||||
if (!uuid.test(options.accountId)) throw fault('invalid_account_identity');
|
||||
const requestFetch = options.fetch ?? globalThis.fetch;
|
||||
@@ -51,6 +52,7 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti
|
||||
} });
|
||||
if (!options.isCurrentIdentity()) { await response.body?.cancel(); throw fault('identity_changed'); }
|
||||
// 成功的资源字节读取同时约束 Content-Length 提示和实际累计20MiB,不能只信响应头。
|
||||
// 分块仍全部保留至 Blob 构造;这是载荷预算,不是总堆内存上限,也不验证摘要或图片解码。
|
||||
if(input.responseFormat==='asset-bytes-v1'&&response.status===200){
|
||||
const maximum=20*1024*1024,length=response.headers.get('content-length');
|
||||
if(length!==null&&(!/^\d+$/.test(length)||Number(length)>maximum)){await response.body?.cancel();throw fault('asset_byte_budget_exceeded');}
|
||||
@@ -70,7 +72,8 @@ export function createFoundryTransport(options: { accountId: Id; isCurrentIdenti
|
||||
if(!response.body)throw fault('incomplete_model_stream');
|
||||
const body=response.body;
|
||||
/** 惰性读取字节,每个 await 两侧复查身份;迭代结束、抛错或消费者提前退出都归还 reader 锁。
|
||||
* NDJSON 内容和字节预算由 model-stream 解释器执行,此处只提供有生命周期边界的字节源。 */
|
||||
* NDJSON 内容和字节预算由 model-stream 解释器执行,此处只提供有生命周期边界的字节源。
|
||||
* reader 在首次迭代时才取得;从不消费此生成器时,不会进入这里的 finally 清理。 */
|
||||
async function* stream(){
|
||||
const reader=body.getReader(),abort=()=>{void reader.cancel().catch(()=>{});};input.signal?.addEventListener('abort',abort,{once:true});
|
||||
try{while(true){input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');const next=await reader.read();input.signal?.throwIfAborted();if(!options.isCurrentIdentity())throw fault('identity_changed');if(next.done)break;yield next.value;}}
|
||||
@@ -147,6 +150,7 @@ function statusFor(request: FrozenRequest, response: ServiceResponse, query: boo
|
||||
return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json });
|
||||
}
|
||||
if (matching && body.status === 'accepted' && response.status >= 200 && response.status < 300) return { outcome: 'accepted', retryAfterMs: response.retryAfterMs };
|
||||
// 终结未生效分支只要求协议/请求匹配和 terminal;不复用 applied 的 HTTP 与操作覆盖校验。
|
||||
if (matching && body.status === 'not-applied' && body.terminal === true) return { outcome: 'not-applied', error: error ?? 'not_applied' };
|
||||
// 查询不到回执不等于从未执行,继续使用原请求身份查证;普通4xx未提供终结证据时阻塞。
|
||||
if (query && response.status === 404 && body.status === 'unknown' && body.request_id === request.id) return { outcome: 'unresolved', error: 'operation_receipt_unknown', retryAfterMs: response.retryAfterMs };
|
||||
@@ -154,7 +158,8 @@ function statusFor(request: FrozenRequest, response: ServiceResponse, query: boo
|
||||
return { outcome: 'unresolved', error: error ?? 'transport_unknown', retryAfterMs: response.retryAfterMs };
|
||||
}
|
||||
/** 发送/查询/解释前交叉核对冻结元数据与请求体:协议、request_id、预期版本、画布集合和操作覆盖一致。
|
||||
* 不在这里重新编译 payload,重试必须沿用已经保存的精确 body。 */
|
||||
* 不在这里重新编译 payload,重试必须沿用已经保存的精确 body。
|
||||
* 这是冻结描述的一致性检查,不重新执行每项 change 的 schema、条件或依赖验证。 */
|
||||
function validateFrozen(request: FrozenRequest) {
|
||||
let body: Record<string, unknown>;
|
||||
try { body = record(JSON.parse(request.body)); } catch { throw fault('invalid_frozen_request'); }
|
||||
@@ -207,15 +212,17 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id;
|
||||
}
|
||||
// The body is the durable coverage mapping. Never replace logical IDs with a synthetic batch ID.
|
||||
// projectBatch 基于 ACK 与 ledger 合并条件字段,同时保留逻辑覆盖;JSON复制使描述脱离调用方引用。
|
||||
// 返回值尚不是持久冻结的 body 字符串;保存请求、调度和重试由 runtime 完成。
|
||||
const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: projectBatch(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger, canvasIds) })) as Json;
|
||||
if (!validJson(payload)) throw fault('invalid_operation_payload');
|
||||
return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload });
|
||||
}
|
||||
return Object.freeze({
|
||||
/** 此适配器仅解释远端协议v2,恢复的其它版本不能猜测着继续发送。 */
|
||||
/** 协议版本能力门,只声明可解释 v2;不是对冻结 body 完整合法性的认证。 */
|
||||
supportsRequest: (request: FrozenRequest) => request.protocolVersion === 2,
|
||||
/** 仅在调用方已协调成功且请求resolved/applied后尝试摘要。
|
||||
* 检查参与版本与已采用结果一致,未知字段/未知组件/额外格式信息一律保留原始回执。 */
|
||||
* 检查参与版本与已采用结果一致,未知字段/未知组件/额外格式信息一律保留原始回执。
|
||||
* 此处信任 applied 已经协调采用,不再次逐元素比较 applied.document;摘要不能独立重建文档。 */
|
||||
...(options.resolvedReceiptPolicy==='summary'?{compactResolvedReceipt(request:FrozenRequest,receipt:Json,applied:import('../canvas-runtime/contracts.ts').CanonicalProposal):Json{
|
||||
if(request.kind!=='document.change'||request.protocolVersion!==2||request.phase!=='resolved'||request.outcome!=='applied')return receipt;
|
||||
validateFrozen(request);const body=record(receipt);
|
||||
@@ -257,6 +264,7 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id;
|
||||
query: (request: FrozenRequest, signal: AbortSignal) => execute(request, true, signal),
|
||||
/** 从原始回执提取参与者模型,合成新的 ACK 候选;不接受本地已压缩摘要代替原模型。 */
|
||||
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot) {
|
||||
// 按已确认的 applied 结果调用;此处核对身份并组装模型候选,不代替结果解释或版本推进检查。
|
||||
assertRawReceipt(status.canonical);const body = record(status.canonical);
|
||||
if (body.request_id !== request.id || body.protocol_version !== 2) throw fault('receipt_identity_mismatch');
|
||||
return mergeFoundryModel(body.model, request.canvasIds, snapshot, options.registry, options.rootId);
|
||||
@@ -280,6 +288,7 @@ export function verifyFoundryModelProof(value:unknown,participants:readonly Id[]
|
||||
if(!equal(canvases.get(canvas),proposal.document.canvases.get(canvas)))throw fault('canonical_version_mismatch',[canvas]);
|
||||
unchanged.add(canvas);
|
||||
}
|
||||
// 更高版本跳过逐字段相等检查,允许后续合法修改;同版本(包括删除记录)必须保持既有内容。
|
||||
// 对仍处于同版本的 canvas 逐元素比较,既不能缺元素也不能多元素;墓碑只能单调推进。
|
||||
const expected=new Map(chunk.elements.filter(element=>unchanged.has(element.ownerCanvasId)).map(element=>[element.id,element]));
|
||||
let count=0;for(const element of proposal.document.elements.values())if(unchanged.has(element.ownerCanvasId)){count++;if(!equal(expected.get(element.id),element))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);}
|
||||
@@ -294,7 +303,9 @@ export function verifyFoundryModelProof(value:unknown,participants:readonly Id[]
|
||||
export function mergeFoundryModel(value: unknown, participants: readonly Id[], snapshot: EngineSnapshot, registry: Registry, rootId: Id) {
|
||||
const chunk = modelChunk(value);
|
||||
if (chunk.projectId !== rootId || chunk.complete || chunk.versions.size !== participants.length || participants.some(id => !chunk.versions.has(id))) throw fault('receipt_coverage_mismatch');
|
||||
// 必须从 ACK 构造,不把 working 中尚未确认的本地内容冒充服务端已接纳内容。
|
||||
const previous = snapshot.runtime.acknowledged, canvases = new Map(previous.canvases), elements = new Map(previous.elements), tombstones = new Map(previous.tombstones), serverVersions = new Map(snapshot.runtime.versions);
|
||||
// 先全部删除参与 canvas;removed 参与者不在返回列表内,因此保留版本但不重新放回画布记录。
|
||||
for (const canvas of participants) canvases.delete(canvas);
|
||||
for (const canvas of chunk.canvases) canvases.set(canvas.id, canvas);
|
||||
// 先移除旧参与者成员,再加入回执成员;与非参与者现有ID碰撞则拒绝,不能跨范围覆盖。
|
||||
@@ -326,6 +337,7 @@ export function mergeFoundryRunContext(value: unknown, snapshot: EngineSnapshot,
|
||||
for(const canvas of chunk.canvases)if(replace.has(canvas.id))canvases.set(canvas.id,canvas);
|
||||
for(const [id,element] of elements)if(replace.has(element.ownerCanvasId))elements.delete(id);
|
||||
for(const element of chunk.elements)if(replace.has(element.ownerCanvasId)){if(elements.has(element.id))throw fault('canonical_scope_mismatch',[element.id]);elements.set(element.id,element);}
|
||||
// 墓碑取全上下文的逐 ID 最大值,即使对应 canvas 因版本较旧未替换,也不能让生命周期倒退。
|
||||
for(const [id,generation] of chunk.tombstones)tombstones.set(id,Math.max(tombstones.get(id)??0,generation));
|
||||
const document=createDocument(rootId,[...canvases.values()],[...elements.values()],tombstones);validateDocument(document,registry);
|
||||
return Object.freeze({document,versions:readMap(serverVersions)});
|
||||
|
||||
@@ -19,6 +19,7 @@ export function subgraphMoveParticipants(documents: Map<string, CanvasDocument>,
|
||||
for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen);
|
||||
};
|
||||
for (const node of documents.get(source)?.nodes || []) if (ids.includes(node.id) && node.data.subgraph) addDescendants(node.data.subgraph.boardId);
|
||||
// 祖先端口影响只检查 parent 的直接上一层及其一层内端口映射,不是无限上溯的依赖闭包。
|
||||
const ancestor = parents.get(parent);
|
||||
if (ancestor) {
|
||||
const frame = documents.get(ancestor.boardId)?.nodes.find((node) => node.id === ancestor.frameId);
|
||||
@@ -36,6 +37,7 @@ export function subgraphMovePath(documents: Map<string, CanvasDocument>, source:
|
||||
const parents = subgraphParents(documents);
|
||||
/** 包含自身的父链,重复board立即拒绝,避免循环遍历。 */
|
||||
const chain = (board: string) => { const result = [board]; while (parents.has(board)) { board = parents.get(board)!.boardId; if (result.includes(board)) throw new Error("subgraph_cycle"); result.push(board); } return result; };
|
||||
// 相同 source/target 得到空路径;父索引只反映已加载 documents,缺数据不能据此认定全局无共同根。
|
||||
const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id));
|
||||
if (!common) throw new Error("ownership_conflict");
|
||||
const path: { parent: string; frame: string; source: string; target: string }[] = [];
|
||||
@@ -56,6 +58,7 @@ export function exposedConnection(documents: Map<string, CanvasDocument>, source
|
||||
while (boardId !== owner) {
|
||||
const parent = parents.get(boardId)!;
|
||||
const frame = documents.get(parent.boardId)?.nodes.find((node) => node.id === parent.frameId);
|
||||
// handle 按 === 精确匹配,包括 undefined 与 null 的差别;不会在这里规范化端口表示。
|
||||
const port = frame?.data.ports?.find((port) => port.nodeId === id && port.handle === handle && port.direction === direction);
|
||||
if (!port) throw new Error("boundary_port_required");
|
||||
id = parent.frameId; handle = `port:${port.id}`; boardId = parent.boardId;
|
||||
@@ -71,5 +74,6 @@ export function connectionTransactionPlan(documents: Map<string, CanvasDocument>
|
||||
const path = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const from = path(source.boardId), to = path(target.boardId), owner = from.find((id) => to.includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
// 参与集按 source→owner、target→owner 首次出现顺序去重;这只是规划,不带服务版本或原子提交证明。
|
||||
return { boardId: owner, participants: [...new Set([...from.slice(0, from.indexOf(owner) + 1), ...to.slice(0, to.indexOf(owner) + 1)])], source: { board_id: source.boardId, node_id: source.id, handle: source.handle }, target: { board_id: target.boardId, node_id: target.id, handle: target.handle } };
|
||||
}
|
||||
|
||||
@@ -73,12 +73,14 @@ export class CanvasSync {
|
||||
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
|
||||
this.request = request; this.path = path; this.version = initial.version;
|
||||
this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify;
|
||||
// 保留文档引用,不做构造时解码/深复制;宿主须传入合法快照且避免原地修改在途对象。
|
||||
this.document = initial.document;
|
||||
}
|
||||
/** 只有持久内容变化才需PATCH,单纯平移/缩放视口不主动写;内容保存时payload仍携当前viewport。 */
|
||||
needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; }
|
||||
/** 替换等待槽,有错误时只保留内容而不自动发送;无错误且未运行时启动一个微任务drain。 */
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
// 等待槽借用传入引用;若宿主随后原地修改,drain 读到的内容也会改变,必须以新对象提交编辑。
|
||||
this.pending = document;
|
||||
if (this.error) return Promise.resolve();
|
||||
if (!this.running) this.running = Promise.resolve().then(() => this.drain()).finally(() => { this.running = null; if (this.pending && !this.error) void this.enqueue(this.pending); });
|
||||
@@ -99,6 +101,7 @@ export class CanvasSync {
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const document = this.pending; this.pending = null;
|
||||
// saving 通知在 try 外;notify 应不抛错,否则 running 会拒绝且本次 document 不进入失败回填。
|
||||
this.notify({ status: "saving", version: this.version });
|
||||
try {
|
||||
const fingerprint = canvasFingerprint(document);
|
||||
@@ -110,6 +113,7 @@ export class CanvasSync {
|
||||
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
|
||||
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
|
||||
// 只有通过版本回执后推进保存基线;在途期间新enqueue留在pending供下一轮处理。
|
||||
// 下方保存状态回调仍在 try 内;它若抛错会走 catch,此时版本和部分保存状态已经推进。
|
||||
this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
|
||||
this.document = canvasPayload(document);
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
@@ -128,6 +132,7 @@ export class CanvasSync {
|
||||
* 格式错误继续抛出,不将损坏草稿当作空白文档自动覆盖。 */
|
||||
export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null {
|
||||
const record = storage.getItem(key + ":recovery");
|
||||
// 新键存在且内容非空便独占此次恢复判断;即使返回 null 也不再尝试旧键。
|
||||
if (record) {
|
||||
const value = JSON.parse(record);
|
||||
const doc = decodeCanvas(JSON.stringify(value.document));
|
||||
@@ -144,5 +149,6 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id));
|
||||
// 端口数组保留顺序,JSON 表示参与比较;不比较正文、几何或所有跨图端点。
|
||||
return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type CanvasDocuments = Map<string, CanvasDocument>;
|
||||
/** 根board保留原ID,其余加board::前缀,避免旧协议不同board里的同名元素显示碰撞。 */
|
||||
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
|
||||
/** 路径位置点应用平移+缩放,切线/首尾控制量是向量只缩放;inverse将显示路径转换回owner空间。
|
||||
* 没有path时复制data,避免调用方修改投影时改变原始对象。 */
|
||||
* 没有path时深复制data;有path时只重建路径几何层,其余扩展对象仍可能与原data共享引用。 */
|
||||
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
|
||||
if (!data?.path) return data ? structuredClone(data) : undefined;
|
||||
const { origin, scale } = reference;
|
||||
@@ -51,6 +51,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
for (const edge of document.edges) pending.push({ edge, ref: reference });
|
||||
}
|
||||
visit(root, { x: 0, y: 0 }, 1, new Set());
|
||||
// 仅遍历祖先链防环,不全局去重同一子 board 的多入口;调用方须先保证单父树和显示 ID 合法。
|
||||
const visible = new Set(nodes.map((node) => node.id));
|
||||
/** 展开入口且端口有效时递归寻内层显示端点;找不到已显示内层就保留外侧入口端口。 */
|
||||
function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } {
|
||||
@@ -75,6 +76,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
/** A selected child and its selected parent frame must not receive the same drag twice. */
|
||||
/** 父框与子节点同时选中拖动时,先递归取得父框移动后的显示原点,避免同一拖动位移叠加两次。 */
|
||||
export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map<string, CanvasReference>, boardId: string, moves: Map<string, { x: number; y: number }>): CanvasReference {
|
||||
// boards 必须来自合法投影;此递归不另建 seen 或深度限制,不接受任意含环父引用表。
|
||||
const reference = boards.get(boardId);
|
||||
if (!reference) throw new Error("invalid_reference");
|
||||
if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference;
|
||||
@@ -94,6 +96,7 @@ const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => val
|
||||
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
|
||||
/** 仅普通对象可逐字段递归,数组/标量两侧不同改动不会“最后写入获胜”,而是抛version_conflict。 */
|
||||
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
|
||||
// 未改变的分支直接借用另一侧对象;本合并不是全图深复制,采用后仍须遵守文档不可变约定。
|
||||
if (equal(local, base)) return remote;
|
||||
if (equal(remote, base) || equal(local, remote)) return local;
|
||||
if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) {
|
||||
@@ -109,6 +112,7 @@ export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument
|
||||
}
|
||||
/** 排除选择/拖动/测量/style这些框架字段,它们不该引发业务字段合并冲突。 */
|
||||
const strip = <T extends { selected?: boolean; dragging?: boolean; measured?: unknown; style?: unknown }>(value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; };
|
||||
// schema 取两侧最大值只选择表示版本,不执行格式迁移或最终整图解码验证。
|
||||
const result = {
|
||||
schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2,
|
||||
nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[],
|
||||
|
||||
@@ -19,9 +19,10 @@ export const pointValid = (value: unknown): value is XYPosition => !!value && ty
|
||||
export function fields(value: unknown, keys: string[]): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
/** 所有v2校验失败统一抛invalid_canvas,不返回半份有效扩展。 */
|
||||
/** 显式条件校验失败统一抛 invalid_canvas;JSON.stringify/structuredClone 等原生异常仍可能直接传播。 */
|
||||
function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); }
|
||||
/** 字段缺失可通过,字段存在时必须满足校验;存在但undefined不等同于缺失。 */
|
||||
// 使用 in 同时识别继承字段;外部 JSON 解码结果通常是普通自有字段对象,勿把此助手视为原型净化。
|
||||
function optional(object: Record<string, unknown>, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); }
|
||||
/** 构造封闭枚举校验器,不接受任意扩展字符串。 */
|
||||
const choice = (...values: unknown[]) => (value: unknown) => values.includes(value);
|
||||
@@ -76,6 +77,7 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record<string, unknown>;
|
||||
optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v);
|
||||
optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384);
|
||||
// parameters 这里只检查对象外壳和 JSON 编码大小;不解析各模型参数 schema 或证明服务端支持。
|
||||
extensions.ai = structuredClone(ai) as AIConfig;
|
||||
}
|
||||
if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame");
|
||||
@@ -99,6 +101,7 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
* 内联、demo或其它地址原样返回,不把任意HTTPS资源都伪装成受管资源。 */
|
||||
export function managedImageSource(src?: string, preview = false) {
|
||||
if (!src) return src;
|
||||
// 此兼容路径固定项目名 endoleap,不读取 NEXT_PUBLIC_FOUNDRY_PROJECT;命中后舍弃原查询/片段。
|
||||
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : "");
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
|
||||
@@ -45,12 +45,14 @@ export function canvasStorageKey(workspace: string, board: string) {
|
||||
return `endoleap_canvas_v1:${workspace}:${board}`;
|
||||
}
|
||||
/** 把旧present压入past,截留最近40项并清空redo;checkpoint可记录相同文档对象作为手势起点。 */
|
||||
// 仅保存文档引用,不深复制或做相等判断;调用方必须保持历史快照不可变,空修改也可能占一个撤销项。
|
||||
function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory {
|
||||
return { past: [...state.past, state.present].slice(-40), present, future: [] };
|
||||
}
|
||||
/** 纯快照归约。动作是否产生撤销单元由各分支明确决定,不能假定每次pointermove或字符输入都入栈。 */
|
||||
export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
|
||||
switch (action.type) {
|
||||
// patch 直接合入 data 并升级表示版本;reducer 不调用 schema 解码,输入合法性由动作发起方保证。
|
||||
case "node-data": return recorded(state, { ...state.present, schema_version: 2, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, ...action.patch } } : node) });
|
||||
// 连线预览可只改present并清redo,最终逻辑编辑则记录旧快照。
|
||||
case "edge-data": {
|
||||
@@ -58,7 +60,9 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
return action.transient ? { ...state, present, future: [] } : recorded(state, present);
|
||||
}
|
||||
// 放下内容才重新算旧frameId归属;拖动/缩放分区本身不在此改变成员关系。
|
||||
// drop 沿用已有 past/future,不清 redo;宿主须将它与实际拖动的 checkpoint/transform 边界配合使用。
|
||||
case "drop": return { ...state, present: { ...state.present, nodes: assignFrameMembership(state.present.nodes, action.ids) } };
|
||||
// 重命名/分区/样式动作直接记录,不因目标缺失或值相等自动省略历史。
|
||||
case "rename-node": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, title: action.title } } : node) });
|
||||
case "frame": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id && node.data.kind === "frame" ? { ...node, data: { ...node.data, ...(action.title !== undefined ? { title: action.title } : {}), ...(action.labelMode ? { labelMode: action.labelMode } : {}) } } : node) });
|
||||
// 交互起点可显式入栈,后续edit/transform不逐帧增加历史项。
|
||||
@@ -87,6 +91,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
case "style": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, textStyle: { ...node.data.textStyle, ...action.style }, ...(action.fontSize !== undefined ? { fontSize: action.fontSize } : {}) } } : node) });
|
||||
// 连续几何变化同步修改width/height、measured及style;子图frame保存对应展开/折叠尺寸。
|
||||
case "transform": {
|
||||
// 宽高必须同时给出才更新尺寸;只给单轴时该轴也不生效,position/rotation/fontSize 各自独立。
|
||||
const patch = action.transform;
|
||||
return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : {
|
||||
...node,
|
||||
@@ -146,6 +151,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
else position.x += 300;
|
||||
}
|
||||
// 仅选中新卡片并分配独立边ID;目标handle取旋转后主方向的反侧,源handle仍保留指定方向名。
|
||||
// at 一旦提供便覆盖上述避让位置;指定落点不会再验证重叠,返回文档也不自动登记 history。
|
||||
const child = { ...makeCanvasNode(kind, at || position, src), selected: true };
|
||||
const opposite: Record<NodeDirection, NodeDirection> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
||||
return {
|
||||
@@ -158,6 +164,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
/** 接受有限长度的允许格式base64内联图或无用户名/密码的HTTPS源,拒绝其它scheme。 */
|
||||
function validImage(src: unknown): src is string {
|
||||
if (typeof src !== "string") return false;
|
||||
// 只检查字符串表示和长度,不验证 base64 解码字节、图片格式或远端可访问性。
|
||||
if (/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(src)) return src.length < 3_000_000;
|
||||
try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; }
|
||||
catch { return false; }
|
||||
@@ -182,6 +189,7 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").
|
||||
* 校验单board内容和其端口外壳,跨board子树/目标存在性需要上层联合加载与事务校验。 */
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
// JSON.parse 异常直接传播;整个文件先入内存再解释,字节上限不是流式内存预算。
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || ![1, 2].includes(value.schema_version) || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
throw new Error("不是有效的啡跃图谱文件。");
|
||||
@@ -221,6 +229,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
// frameId仅可引用本board真实frame,不能让旧平面成员关系指向一个普通节点。
|
||||
const frameIds = new Set(nodes.filter((node) => node.data.kind === "frame").map((node) => node.id));
|
||||
if (nodes.some((node) => node.data.frameId && !frameIds.has(node.data.frameId))) throw new Error("分区归属不存在。");
|
||||
// 以下校验本 board 的端点/端口语法,不检测生成执行图环或加载子 board 验证端口内部目标。
|
||||
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
|
||||
const edgeIds = new Set<string>();
|
||||
// 边身份唯一且两端存在;v2分区必须通过声明的input/output port连接,其它允许节点使用四向handle。
|
||||
|
||||
@@ -9,10 +9,12 @@ import type { Board } from "./workspace";
|
||||
export function readDemoBoards(workspace: string): Board[] {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem("endoleap_demo_boards_" + workspace) || "null");
|
||||
// 合法空数组就是空目录,不触发样例回退;version 用 || 补默认,不验证正安全整数。
|
||||
if (Array.isArray(stored)) return stored.map((board) => ({
|
||||
...board, workspace_id: workspace, kind: "board", version: board.version || 1,
|
||||
}));
|
||||
} catch { /* Fall back to the sample pages when browser storage is invalid. */ }
|
||||
// map 中任一条目读取失败也由外围 catch 回退整份样例,不保留此前已映射的部分结果。
|
||||
return [
|
||||
{ id: "morning", title: "城市清晨" },
|
||||
{ id: "ideas", title: "灵感收集" },
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
/** Application adapter. Domain/editor packages must not import Sonner. */
|
||||
/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。 */
|
||||
/** 稳定id用于更新/关闭同一通知,level决定显示样式,description是可选补充文案。
|
||||
* level 的联合类型是调用约束,运行时不另行验证;调用方必须提供通知库支持的级别。 */
|
||||
export type Notification = { id: string; title: string; description?: string; level?: "info" | "success" | "warning" | "error" };
|
||||
/** 按指定level调用Sonner,默认info;相同id的合并/更新行为交给通知库。 */
|
||||
export function notify({ id, title, description, level = "info" }: Notification) {
|
||||
|
||||
@@ -13,6 +13,7 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
|
||||
// cache保存完成值,jobs保存当前可加入的尝试,queue只保存尚未占并发槽的job。
|
||||
const cache = new Map<string, T>();
|
||||
const jobs = new Map<string, Job>();
|
||||
// FIFO 使用数组 shift/splice;不是按优先级排序的 heap,也不持久化等待项或失败重试。
|
||||
const queue: Job[] = [];
|
||||
let active = 0;
|
||||
/** 命中后移到Map尾部,更新近用顺序;只读缓存,不启动网络。 */
|
||||
@@ -34,6 +35,7 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
|
||||
return load(job.key, job.controller.signal);
|
||||
}).then((value) => {
|
||||
if (!job.controller.signal.aborted) {
|
||||
// 缓存直接借用 load 的结果引用;订阅者共享同一对象,调用方须保证可安全共享/只读。
|
||||
cache.set(job.key, value);
|
||||
// 超出缓存容量淘汰最久未触及key,不影响仍在运行的其它job。
|
||||
while (cache.size > cacheSize) cache.delete(cache.keys().next().value!);
|
||||
@@ -74,5 +76,6 @@ export function createResourceLoader<T>(load: (key: string, signal: AbortSignal)
|
||||
pump();
|
||||
});
|
||||
}
|
||||
// 实例没有 dispose/clear:宿主通过订阅信号释放任务;缓存随实例不再被引用后交给 GC。
|
||||
return { request, peek };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 服务端会话Cookie的纯字符串与寿命辅助,不读取或写入浏览器Cookie。
|
||||
* 调用方决定何时采用上游过期提示、是否Secure,并把返回字符串放进响应Set-Cookie。
|
||||
*/
|
||||
/** 本应用默认空闲期上限30天,单位秒。 */
|
||||
/** 会话 Cookie 的默认寿命上限30天,单位秒;是否滑动续期由调用方重新设置 Cookie 决定。 */
|
||||
export const SESSION_IDLE_SECONDS = 30 * 24 * 60 * 60;
|
||||
/** 上游向代理传递剩余会话寿命的响应头名,不是Cookie名。 */
|
||||
export const SESSION_EXPIRY_HEADER = "X-Foundry-Session-Expires-In";
|
||||
@@ -12,7 +12,8 @@ export function sessionMaxAge(value: unknown, fallback = SESSION_IDLE_SECONDS) {
|
||||
const seconds = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
return Number.isSafeInteger(seconds) && seconds >= 0 ? Math.min(seconds, SESSION_IDLE_SECONDS) : fallback;
|
||||
}
|
||||
/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。 */
|
||||
/** URL编码会话值,固定HttpOnly/SameSite=Lax/根路径并规范Max-Age;secure参数为true才添加Secure。
|
||||
* maxAge 非法时使用默认30天,不自动推断为登出;明确清除须传0。此处不设置 Domain 或 Expires。 */
|
||||
export function sessionCookie(value: string, maxAge: number, secure: boolean) {
|
||||
return `foundry_session=${encodeURIComponent(value)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionMaxAge(maxAge)}${secure ? "; Secure" : ""}`;
|
||||
}
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。 */
|
||||
/** clsx负责布尔条件和嵌套类名,twMerge按其规则处理同类覆盖;不解析业务状态或生成CSS。
|
||||
* 后项优先只适用于 twMerge 能识别的冲突组,不能据此保证任意自定义 CSS 都按参数顺序覆盖。 */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function loadWorkspaceHome(
|
||||
request: Request,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
// 任一读取拒绝则整体拒绝,Promise.all 不会自动取消另一请求;共同 signal 由宿主管理。
|
||||
const [profile, result] = await Promise.all([
|
||||
request<Profile>("profile", { signal }),
|
||||
request<Workspace[] | { items: Workspace[] }>("workspaces", { signal }),
|
||||
@@ -62,6 +63,7 @@ export async function createBoard(
|
||||
body: JSON.stringify({ name: "个人空间" }),
|
||||
});
|
||||
id = workspace.id;
|
||||
// 回调在创建画布前同步执行;回调抛错也会阻止后续 POST,但已创建的空间仍存在。
|
||||
onWorkspaceCreated(workspace);
|
||||
}
|
||||
const name = title.trim() || DEFAULT_BOARD_TITLE;
|
||||
|
||||
+6
-2
@@ -142,6 +142,7 @@ app.stdout.on("data", (x) => (startup += x));
|
||||
app.stderr.on("data", (x) => (startup += x));
|
||||
try {
|
||||
// 最多轮询 100 次,失败后等待 100ms,再检查首页是否可用;不能只凭 spawn 返回就宣称服务已启动。
|
||||
// 单次 fetch 没有 AbortSignal 截止时间,因此 100 次是尝试次数上限,不等于严格十秒的启动总超时。
|
||||
let ready = false;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
if (app.exitCode !== null)
|
||||
@@ -200,9 +201,10 @@ try {
|
||||
assert(cookie?.includes("HttpOnly"));
|
||||
assert(cookie.includes("SameSite=Lax"));
|
||||
assert(cookie.includes("Max-Age=2592000"));
|
||||
// Node fetch 不在此维护浏览器 cookie jar;从登录响应取 name=value 后,后续受认证请求显式携带 Cookie。
|
||||
const session = cookie.split(";")[0];
|
||||
// Hold the upstream tail: snapshot headers and its first chunk must arrive
|
||||
// before the proxy has the full JSON body (the old buffering path deadlocks).
|
||||
// 上游尾部等 releaseStream 才返回,先取响应头和首块,再放行尾部;整体缓冲代理会在此形成循环等待。
|
||||
// 2500ms 的 Promise.race 只限制 fetch 返回响应头,后面的 reader.read 不在该 deadline 内,不能据此声称首块也受同一计时约束。
|
||||
let streamTimer;
|
||||
try {
|
||||
const streamed = await Promise.race([fetch(host + "/api/foundry/workspaces/space/materials/stream-board/canvas", { headers: { Cookie: session } }), new Promise((_, reject) => { streamTimer = setTimeout(() => reject(new Error("Snapshot proxy buffered the body")), 2500); })]);
|
||||
@@ -236,6 +238,7 @@ try {
|
||||
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/bad", { headers: { Cookie: session } })).status, 502);
|
||||
const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png");
|
||||
const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json();
|
||||
// 这里只核对 multipart Content-Type、image 字段标记及幂等键;未解析 multipart,也未逐字节比较上传文件内容。
|
||||
assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart);
|
||||
// 有效会话首页重定向且不缓存私有结果;dashboard 初始 HTML 不应包含画布或 landing。
|
||||
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
|
||||
@@ -319,6 +322,7 @@ try {
|
||||
assert.deepEqual(renamed.content, stored.content);
|
||||
|
||||
// workspace/board 各自使用 ID 或 slug 的四种组合都应定位同一图谱和独立画布版本。
|
||||
// 替身通过 canonical 字符串替换提供该映射;此处验证代理接受四种路径,不验证真实 Foundry 的 slug 解析实现。
|
||||
for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) {
|
||||
const path = `/api/foundry/workspaces/${workspace}/materials/${board}`;
|
||||
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
|
||||
|
||||
@@ -21,6 +21,7 @@ test('missing parents and corrupt cycles remain accessible without recursive loo
|
||||
const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0));
|
||||
});
|
||||
// 非递增 next_offset 明确拒绝;已经 abort 的信号由请求替身抛回,加载器不得吞掉取消。
|
||||
// 取消样本在请求开始前触发,不验证分页中途取消或真实 fetch 是否停止下载。
|
||||
test('directory rejects non-advancing pagination and propagates aborts',async()=>{
|
||||
await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/);
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'});
|
||||
|
||||
@@ -12,6 +12,7 @@ const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
|
||||
const id='77777777-7777-4777-8777-777777777777';
|
||||
const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'};
|
||||
// 首例直接比对 FormData.image 原字节、上传路径、POST 和幂等键形状,返回值保留服务资产元数据。
|
||||
// 键的正则只检查 36 位字符集合,不完整校验 UUID 分段;替身返回固定元数据,不证明服务器实际解码成功。
|
||||
test('attachment upload sends the original file bytes',async()=>{
|
||||
const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0;
|
||||
const result=await uploadCanvasImage(async(path,options)=>{
|
||||
@@ -32,6 +33,7 @@ test('uncertain upload retries the exact bytes with the same idempotency key',as
|
||||
await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2);
|
||||
});
|
||||
// 受管原始 URL 转同源认证代理,preview 仅加显示变体参数;普通外部图片地址保持原样。
|
||||
// 这里仅比对字符串,不请求代理,也不验证登录鉴权或预览图片内容。
|
||||
test('preview URLs are renderer-only and do not replace original managed references',()=>{
|
||||
assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`);
|
||||
assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user