Compare commits

...

55 Commits

Author SHA1 Message Date
codex-bot 97fefaa519 docs(canvas): explain menu focus and toolbar update boundaries 2026-11-12 16:24:09 +08:00
codex-bot c34d0b7ce0 docs(canvas): explain node rendering and resource handoffs 2026-11-12 16:21:06 +08:00
codex-bot f28d3810d4 docs(canvas): clarify editing guards and projection lifetimes 2026-11-12 16:18:24 +08:00
codex-bot 65c54e7b9e docs(workspace): explain component state and async ownership 2026-11-12 16:14:21 +08:00
codex-bot cc2172319e docs(ui): explain page and shared component lifecycle boundaries 2026-11-12 16:11:24 +08:00
codex-bot 5d82655e0e docs(canvas): explain legacy document and sync state boundaries 2026-11-12 16:08:02 +08:00
codex-bot 9b24319747 docs(canvas): explain legacy resources and geometry ownership 2026-11-12 16:04:32 +08:00
Zayden-Jung b995e659a6 Merge pull request 'docs: 完善协议、公共库与画布迁移注释' (#13) from codex/foundry-protocol-comments into main
Reviewed-on: #13
2026-11-12 08:03:32 +00:00
codex-bot 5803787910 docs(canvas): clarify entry and migration lifecycle 2026-11-12 16:01:25 +08:00
codex-bot 84c50fe595 docs(app): explain request and directory lifecycle boundaries 2026-11-12 15:57:33 +08:00
codex-bot f78618be2b docs(service): clarify Foundry protocol validation boundaries 2026-11-12 15:54:01 +08:00
Zayden-Jung 18913efab7 Merge pull request 'docs(canvas): 完善画布模块与函数的逻辑注释' (#12) from codex/runtime-replay-comments into main
Reviewed-on: #12
2026-11-12 07:50:38 +00:00
codex-bot 9fa0958eb8 docs(service): explain run admission and event validation 2026-11-12 15:46:27 +08:00
codex-bot 43af2aeceb docs(service): explain asset receipts and model stream boundaries 2026-11-12 15:42:24 +08:00
codex-bot 27311e831d docs(bindings): explain React hooks and renderer ownership 2026-11-12 15:37:19 +08:00
codex-bot ce05ab653d docs(bindings): explain canvas projection and connection paint bounds 2026-11-12 15:32:19 +08:00
codex-bot 04c4566679 docs(bindings): explain derived sources and renderer lease contracts 2026-11-12 15:28:00 +08:00
codex-bot 30978320c3 docs(bindings): explain scene patching and paint cache ownership 2026-11-12 15:23:17 +08:00
codex-bot 7297985829 docs(bindings): explain projection geometry and hit testing budgets 2026-11-12 15:18:42 +08:00
codex-bot cb14e8e6d2 docs(bindings): explain input ownership and subscription lifecycle 2026-11-12 15:14:02 +08:00
codex-bot 7cdd2db4d7 docs(runtime): explain public recovery and engine shutdown contracts 2026-11-12 15:09:51 +08:00
codex-bot 75342cb7ad docs(runtime): explain import runner assembly and controls 2026-11-12 15:04:33 +08:00
codex-bot 7e08dd996b docs(runtime): explain engine image and import preparation lifecycle 2026-11-12 15:00:22 +08:00
codex-bot b3a634ce6b docs(runtime): explain engine file checks and response reconciliation 2026-11-12 14:55:15 +08:00
codex-bot 56b83e2009 docs(runtime): explain engine assembly and durable prefix writes 2026-11-12 14:50:08 +08:00
codex-bot 9abbe9c029 docs(runtime): explain recovery format and cross-table validation 2026-11-12 14:44:29 +08:00
codex-bot e425b28064 docs(runtime): explain progressive import publication and recovery 2026-11-12 14:40:09 +08:00
codex-bot 9c1dd28f3a docs(runtime): explain generation polling state and lifecycle 2026-11-12 14:35:57 +08:00
codex-bot 85b038a93f docs(runtime): explain browser storage and depot transaction boundaries 2026-11-12 14:31:58 +08:00
codex-bot 0ca2af7a37 docs(runtime): clarify copy snapshot and journal replay contracts 2026-11-12 14:27:28 +08:00
codex-bot a927f36d96 docs(runtime): explain recovery capture and sealing lifecycle 2026-11-12 14:20:03 +08:00
codex-bot c3131a71bd docs(runtime): explain durable storage and recovery JSON contracts 2026-11-12 14:15:02 +08:00
codex-bot 5f2bf2d142 docs(runtime): explain default image sizing grant lifecycle 2026-11-12 14:09:25 +08:00
codex-bot 63c4098e4e docs(runtime): clarify resource footprints and retention scan ownership 2026-11-12 14:05:32 +08:00
codex-bot 9c6f17f236 docs(runtime): clarify applied recovery staging and port contracts 2026-11-12 14:01:52 +08:00
codex-bot 9c2ecb9fe2 docs(runtime): explain conflict recovery coordination and audit 2026-11-12 13:56:06 +08:00
codex-bot c83641417b docs(runtime): explain mixed history recovery planning lifecycle 2026-11-12 13:52:23 +08:00
codex-bot a3d92f9cd6 docs(runtime): clarify history event sources and traversal recovery 2026-11-12 13:47:41 +08:00
codex-bot 196866aaf3 docs(runtime): explain partial loading coordination and gates 2026-11-12 13:44:12 +08:00
codex-bot 59b59c2f03 docs(runtime): clarify partial loading structure and import gates 2026-11-12 13:39:25 +08:00
codex-bot 437a46ad36 docs(runtime): explain progressive previews and loading asset evidence 2026-11-12 13:33:34 +08:00
codex-bot 2a9667a287 docs(runtime): clarify scoped loader subscriptions and cache lifecycle 2026-11-12 13:30:05 +08:00
codex-bot 3e4833470a docs(runtime): explain host and workspace ownership lifecycles 2026-11-12 13:27:00 +08:00
codex-bot 896e6a0656 docs(runtime): clarify planning checkpoints and scope evidence 2026-11-12 13:23:33 +08:00
codex-bot 17c14c85e8 docs(runtime): explain prepared and lazy copy sources 2026-11-12 13:20:34 +08:00
codex-bot 020fb55084 docs(runtime): explain indexed document and native import conversion 2026-11-12 13:17:27 +08:00
codex-bot 238e4dd4c5 docs(runtime): explain import proof and identity ownership 2026-11-12 13:14:06 +08:00
codex-bot 3c8953e842 docs(runtime): clarify streaming import cursors and byte reads 2026-11-12 13:10:35 +08:00
codex-bot d02cd2b01d docs(runtime): explain import scheduling and dependency selection 2026-11-12 13:07:14 +08:00
codex-bot 2b0afddd07 docs(runtime): explain digest scheduling and streaming boundaries 2026-11-12 13:04:23 +08:00
codex-bot f6acdac8d8 docs(runtime): explain asset display and image decode lifecycles 2026-11-12 13:01:35 +08:00
codex-bot 9ea8463867 docs(runtime): clarify resource and file retention boundaries 2026-11-12 12:58:15 +08:00
codex-bot c491cd023c docs(runtime): explain wire projection and conflict replanning 2026-11-12 12:54:35 +08:00
codex-bot 20bdc7d44a docs(runtime): clarify elision and reconciliation evidence 2026-11-12 12:51:30 +08:00
Zayden-Jung e78c18b697 Merge pull request 'docs: 完善画布注释并复核核心数据边界' (#11) from codex/storage-case-comments into main
Reviewed-on: #11
2026-11-12 04:50:34 +00:00
162 changed files with 1863 additions and 669 deletions
@@ -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 (
+2 -1
View File
@@ -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
View File
@@ -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">
+4
View File
@@ -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 (
+2
View File
@@ -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 />;
+3
View File
@@ -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;
+4
View File
@@ -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"}
+2
View File
@@ -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后自然移除覆盖。
@@ -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,再开始编辑。
@@ -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} 连接元素`} />)}</>;
}
+2
View File
@@ -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()}>
+2 -1
View File
@@ -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({
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
+1
View File
@@ -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) => {
+1
View File
@@ -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)))
+3
View File
@@ -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后是否关闭由调用方决定。
+2
View File
@@ -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 : ""}`}>
+3 -1
View File
@@ -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对应的动作实现负责。
+3 -1
View File
@@ -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只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
+2
View File
@@ -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[]};
+1
View File
@@ -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" };
@@ -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>;
}
+1
View File
@@ -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展开不发网络请求。
+2 -1
View File
@@ -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}>
+1
View File
@@ -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
+2 -1
View File
@@ -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>
+1
View File
@@ -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
@@ -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]);
@@ -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}`}>
+5
View File
@@ -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()&&currentIdentity?.())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&&currentIdentity?.()){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的活动状态不能覆盖当前名称。
+3 -1
View File
@@ -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 : ""}`}>
+4 -1
View File
@@ -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配置;不是密码或用户账户身份。 */
+3
View File
@@ -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 }] : [];
});
+3
View File
@@ -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();
}),
});
+3 -1
View File
@@ -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;
}
+1
View File
@@ -1,6 +1,7 @@
/**
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
* 本文件仅定义 TypeScript 形状,不运行默认值/迁移/编辑行为;投影器负责从标准 element 生成这些显示字段。
*/
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
type Point={x:number;y:number};
+17 -7
View File
@@ -11,7 +11,9 @@ import {compatConnector} from './connectors.ts';
/** Conservative cubic control hull + half stroke, in the displayed scene space.
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。
* maxEdges 限访问的边数,maxSegments 同时作单边与总段数限;存在待查端点时仍遍历所有 projected nodes。
* 几何回调同步执行且没有时间抢占,回调产出数组前的成本不由预算控制;输入须属于同一有效显示帧。 */
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
@@ -29,21 +31,25 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。
// 签名复制锚点/端点数值/transform,但 record 与 edge 借用原不可变引用,缓存不深捕获它们。
// 外部几何服务/协议配置不在签名内,须通过下方 geometryRevision 或 registry 对象变化失效。
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。
// 锚点与基础输入每轮仍重新求取;签名相等才复用曲线范围/段数,跳过 segments/stroke 计算。
// 空 region 可表示笔画未超原生范围或空 segments,不能将其误当“整条边没有显示内容”。
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
computed++;
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。
// 非空段数超过本次剩余预算则追加全局 paint_budget 并终止后续边;空数组仍要求有效 stroke。
// 空数组缓存0段/无region,属本几何协议声明无笔画,不误标 unknown。
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
// 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。
// 原生范围取两端节点未旋转矩形的联合 AABB;曲线控制点凸包+半线宽 AABB 超出时才添整块补充范围。
// 这是保守估计而非曲线精确极值,允许额外保留可见候选;不计算命中距离或直接控制 renderer 挂载。
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
@@ -56,11 +62,15 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
/** Host revision invalidates geometry services whose external configuration changes. */
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。
* 每视图独立实例;缓存按 registry 对象/project/canvas/revision 隔离,不另核 editor owner。
* cache 保留边几何结果,index 存本轮额外区域;超过预算的其它边可暂留缓存但不进入本轮 regions。 */
export function createConnectionPaintIndex(){
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
* unknown 中元素 ID 表示该边无法解释,字符串 paint_budget 表示余下查询不完整,不能据空 regions 判不可见。
* query 是可变 index 的方法,旧返回对象也查最新网格,unknown 则是本轮冻结列表。
* computed 计通过锚点/变换检查后未命中的几何尝试(可随后失败),不等于成功区域数;clear 不重置累计值。 */
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
+6 -2
View File
@@ -9,7 +9,9 @@ import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometr
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */
* 找到具名 port 时,同 direction 的 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。
* 即使 fallback hint 选择了另一侧,已识别 port 仍按高度做 y 偏移;这是旧布局协议,不适用于原生 Handle。
* nodeConnector 先负责节点边界/旋转,下面直接叠加 y 偏移,不再把这段偏移当局部向量旋转。 */
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
@@ -21,7 +23,9 @@ export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:numbe
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
* 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */
* source 仅查 source 列表;target 按 target 再 source 顺序查,匹配 loose 模式。
* 非空 handleId 精确查找,空字符串/null/undefined 取首项;无匹配或未测量返回 undefined。
* 假定 positionAbsolute 与 Handle 尺寸采用同一场景单位;直接叠加,不在这里验证有限值或再次处理节点旋转。 */
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
+11 -5
View File
@@ -16,7 +16,9 @@ export type ElementSourcePorts={
};
/** One upstream subscription, ID buckets and cached lookup values, never another document.
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */
/** buckets 按 elementId 共享上游查询结果/观察器,每个 forElement 另有即时读取缓存。
* 首次 subscribe 才接上游,全部桶退订才释放 previous 根引用;创建源或直接读不持有上游租约。
* 依赖不可变快照与完整 Commit.changes 合同,不从队列/网络结果猜测元素是否已改变。 */
export function createElementSources(ports:ElementSourcePorts){
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
@@ -28,11 +30,13 @@ export function createElementSources(ports:ElementSourcePorts){
const before=previous!;previous=next;
const ids=new Set<Id>();
if(before.document!==next.document){
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。
// 显式变化只路由 field.target.id / element.id,不按 canvas 变更自动扩散到全体子元素。
// 文档替换且无 changes 时复查全部已订阅桶;路由准确性依赖发布者提供完整变更及 lookup 的依赖合同。
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
else for(const id of buckets.keys())ids.add(id);
}
// loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。
// loading 对象或其读取基线变动可改变 absent/unloaded 判定,因此复查非 present 桶。
// 不将任意 runtime 更新都当元素变化;failed 桶也只有进入本次候选 IDs 才会在此重新查询。
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
const notifications=new Set<()=>void>();
@@ -50,7 +54,8 @@ export function createElementSources(ports:ElementSourcePorts){
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
let value:ElementLookup|undefined;
return Object.freeze({
/** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */
/** 每次查询当前根,用 equal 保持等价 ElementLookup 的局部引用;读取不改共享桶通知基线。
* 不深复制查询值,lookup 必须返回适合不可变使用的结果;异常直接抛给调用方读取边界。 */
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
subscribe(notify:()=>void){
@@ -59,7 +64,8 @@ export function createElementSources(ports:ElementSourcePorts){
const listener=()=>notify();bucket.listeners.add(listener);
// 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
// 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。
// 最后一个桶清空才 stop 上游并释放 previous;同回调的多次订阅因 listener 包装仍分别退订。
// stop 异常不被捕获,后续状态重置不保证执行;宿主的取消订阅端口应不抛错。
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
},
});
+10 -5
View File
@@ -6,23 +6,28 @@ import type {ExternalSource} from './selectors.ts';
/** Presentation invalidations only. Commands, history and network work never wait here. */
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */
/** 创建一条可手动 flush 的通知队列;Set 按键身份去重,并保留首次插入顺序。
* clock/publish 由宿主实现,不读取 DOM/模型;调度器异常原样抛出,不另设重试或全局错误处理。 */
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */
/** 同步排空:先撤当前调度并换新 Set,再 publish,回调中的 invalidate 留给下一批。
* batch 以 ReadonlySet 类型借出但未运行时冻结,与新 dirty 已分离;publish 抛错不把本批自动放回队列。 */
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
return Object.freeze({
/** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */
/** 逐项加入 dirty,至多安排一个帧回调;pending 对象身份防旧回调误刷后来一批。
* request 抛错会清 scheduled,但保留 dirty 供后续 invalidate/flush 重试;不自动再 request。 */
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
flush,
/** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */
/** 标记 disposed 后尽力取消帧并清 dirty;后续 invalidate/flush 无效,不替上游关闭 editor。
* 这里不捕获 clock.cancel 异常,宿主取消端口须可靠清理;重复 dispose 不会重试一次失败取消。 */
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
});
}
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
* 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */
* 上游标 immediate 时先 invalidate 再同步 flush,合并的旧失效也一起通知;读取从不等待帧。
* 同一工厂的不同订阅不共享队列;退订函数每次都会调用上游 stop,幂等性依赖该 stop 合同。 */
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
const queue=createFrameQueue<number>(clock,()=>notify());
+9 -4
View File
@@ -12,21 +12,26 @@ const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.wi
/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */
/** 一个显示上下文使用一份缓存:三个 Map 分别缓存节点几何、子 canvas 变换和连线路径。
* 调用方须按 begin→若干 node/child/edge→end 使用;这里不校验调用配对,也不提供失败回滚/并发帧隔离。
* 累计计数只在实际重算后增加,不是文档版本或当前命中次数;返回投影值冻结,键只保留所需输入。 */
export function createGeometryProjectionCache(){
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
return Object.freeze({
/** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */
/** scope 按对象身份、project/canvas 按 ID 隔离;任一变化清三张表,累计计数不清。
* 每轮重建 seen,随后命中缓存也记一次访问;begin 不自行遍历文档或判断元素仍可见。 */
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
/** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
/** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */
/** 分区入口几何+父变换+contentScale/unitScale 生成内部 canvas→场景变换,单位变化也使缓存失效。
* 不含 collapsed 等显示决策,是否调用 child 由投影器决定;使用核心 childCanvasTransform 解释单位。 */
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
/** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
/** 删除本轮未访问的缓存项,避免浏览多个分区后无限保留旧节点和路径。 */
/** 扫描三张缓存表,删除本轮未访问项;保留规则按调用访问,不等于元素已经从文档删除。
* 调用方若提前抛错且不执行 end,未访问旧项可暂留至下次完整清理,缓存不是业务恢复事实。 */
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
/** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
+22 -10
View File
@@ -29,7 +29,10 @@ const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFin
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
/** Query the captured display frame. It neither edits nor replaces the native input router. */
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。
* 同步消费借入的 projection/query/services,不捕获另一份文档;几何服务须按此显示帧返回同坐标系的结果。
* maxCandidates 约束节点精测+边访问,maxCurveSteps 由全部边共享,maxSegments 则是每边上限;
* 前置 Map/层序/空间候选收集和用户几何回调不被这些计数抢占,不能据此承诺总耗时硬上限。 */
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
// 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
@@ -38,34 +41,43 @@ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries
// 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
// 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。
// 先收集 previewIds,再逐 canvas 反变换查局部索引,以 Set 合并;真正精测前会排除未出现在投影中的 ID。
// 索引几何未随瞬态 preview 改动时,renderer 必须补 previewIds,不能只靠原位置的空间候选。
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
/** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */
/** 从元素真实 owner 沿 parent 回到当前显示根,反转成根→owner 路径;不是修改元素归属。
* 每条命中都生成显示路径,循环/父关系断裂作为该目标诊断;viewSessionId 只沿用调用值,不查询 session 注册表。 */
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
/** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
// 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。
// 节点精测只处理投影中实际存在的候选;unknown/fallback/非 node 几何协议统一用矩形显示外壳。
// confirmed 仅证明采用的显示几何命中,不宣称已经理解未知组件原始轮廓。目标回调须按 Error 风格抛错。
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
// 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。
// bounds 在此用于合法性检查,最终命中由 hit 返回布尔值;不能把通过 bounds 检查当成已经命中。
// geometry 使用 node 的显示尺寸/位置(含预览)反算,element 仍是同帧文档记录,容差换算为 owner 局部单位。
const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');
const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'});
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
// 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
// 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。
// 节点精测在先,连线随后遍历 projection.edges(不再做空间粗筛),两者共用 work 预算。
// 单边解析显示端点/画笔失败记诊断,继续其它边;某些边因节点预算已耗尽可能尚未访问。
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
// renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。
// 自定义 edgeGeometry 非 null/undefined 的结果优先(包括空数组),否则调用已知 connection 协议。
// 先检查每段4个有限点及每边段数,再求距;回调本身仍同步执行,没有超时或抢占。
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
// 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。
// strokeWidth 同样优先服务回调,再用组件协议,要求是场景单位的有限非负数。
// 有效点击半径=场景容差+线宽/2;不能把模型局部宽度或屏幕像素宽度未经换算直接送进来。
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
// 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
// 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
// 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。
// 只核当前 projection.transforms 覆盖的显示 canvas 是否完整;不推断折叠内部的未显示元素。
// 命中列表非空仍可能不完整,diagnostics 汇总几何解释/预算/精度与模型覆盖问题。
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
// 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。
// 排序:layer 降序→同层 node 优先→文档层序倒序,不以 confirmed/possible 抢改显示层级顺序。
// complete 仅等于本次 diagnostics 为空;消费者仍需理解命中 certainty,函数不改变 selection 或手势。
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
}
+1
View File
@@ -1,6 +1,7 @@
/**
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
* 此入口只重导出,不创建 view、订阅或缓存实例;scene coordinator 与分桶源的内部接线不在此集中公开。
*/
/** Portable scene projection and hit queries: no React or React Flow imports. */
// 模型到场景投影、几何缓存和受预算控制的命中查询。
+5 -2
View File
@@ -16,7 +16,8 @@ const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
* This uses a structural DOM port so pure consumers need no browser globals. */
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。
* closest 可查节点的祖先;两个标记独立累计,可同时为 true。DOM 端口异常原样传播,不在此兜底成未编辑。 */
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
let textEditing=false,overlay=false;
for(const target of [event.target,...(event.composedPath?.()??[])]){
@@ -28,7 +29,9 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:(
}
return {textEditing,overlay};
}
/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */
/** 已 defaultPrevented 的事件先返回 unclaimed,即使同时是文本/浮层输入也不再次认领。
* composing/textEditing 返回 text,overlay 返回 overlay;这两层只是上下文判断,不调用下方 handler。
* gesture→tool→shortcut 同步短路,抛错直接传播;返回 claim 不会自行 preventDefault/stopPropagation。 */
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
if(input.defaultPrevented)return 'unclaimed';
if(input.composing||input.textEditing)return 'text';
+6 -3
View File
@@ -3,10 +3,12 @@
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
*/
/** One renderer store's transient measurement requests. Not model or history state. */
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */
/** 每个 renderer store 独立创建:owners 记录挂载 token→nodeId,dirty 只记录待测 token。
* defer 默认为微任务;update 接收执行时仍有效的 ID,不接触文档。工厂没有全局 dispose/flush 公开入口。 */
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
/** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */
/** 执行时解析仍有效 owner 并按 ID 去重,清 queued/dirty 后 update;重入 invalidate 可安排下一轮。
* update 异常直接传播,本批不自动回队重试;卸载 token 的请求被排除,新 token 须自行 invalidate 才入本批。 */
function flush(){
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
if(ids.size)update([...ids]);
@@ -16,7 +18,8 @@ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flus
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */
/** 活跃 token 去重变脏,queued 防重复 defer;多个 token 若同 nodeId,flush 时再合成一个 ID。
* 不捕获 defer 异常:若宿主抛错但未排任务,queued 不自动复位,宿主应使用可靠调度端口。 */
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
dispose(){owners.delete(owner);dirty.delete(owner);},
+10 -5
View File
@@ -9,19 +9,24 @@ import {fault} from '../canvas-core/values.ts';
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */
/** entries 保存 overlayId→{token,entry},snapshot 沿用 Map 插入顺序(替换同 ID 不移位),index 只按屏幕 bounds 查询。
* 不订阅文档/视口,不自行核 target 存活;调用者更新位置,并在 visible 时提供当前目标有效性判断。 */
export function createOverlayIndex(viewId:string){
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */
/** 先更新 snapshot/index,再同步通知监听集合副本;只发布位置事实,不挂载 DOM。
* 监听器异常直接传播且会中断后续通知,不回滚已发布条目;不能据 register/update 抛错推断它未生效。 */
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
/** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */
/** 重建并冻结 entry 外壳/target/bounds,校验 ID/placement/目标代次和合法矩形。
* focused/composing 的布尔值由类型合同约束,此处不另验;扩展字段浅保留,不是任意对象深克隆。 */
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
* 注册时 ++serial 分配 token,覆盖注册使旧租约失效;update 先检查 token,再捕获并按 JSON 文本比较。
* 等值 update 不发布,register 替换始终发布;release/clear 只移除瞬态浮层,不删除元素。 */
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */
/** 先验证查询矩形并查网格,再按 snapshot 顺序筛选:current(target) 必须真,之后才考虑焦点/组合状态或交集。
* 焦点/输入法保留不会绕过目标有效性,也不自动移除过期 entries;返回新数组,不发布位置更新。 */
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
clear(){entries.clear();publish();},
+9 -4
View File
@@ -12,18 +12,23 @@ const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].eve
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */
/** cellSize 决定网格尺度,maxCells 限单区域/查询展开格数,默认512和256。
* rows 存矩形,cells 存格→ID桶,members 存 ID→格键,overflow 存无法安全展开的 ID;全部可从输入重建。
* maxCells 不限制总区域数/总表大小;大区域或大查询保守回退扫描,可能线性耗时。 */
export function createPaintIndex(cellSize=512,maxCells=256){
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */
/** 用 floor 算包含两端边界的格区间,负坐标也一致;恰好压格线的矩形可占相邻两格。
* 不安全整数或格数超 maxCells 返回 undefined,避免巨大循环,随后通过真实矩形交集排除假阳性。 */
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
/** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
return Object.freeze({
/** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */
/** 先复制全部输入矩形并验证非空 ID/唯一性/合法矩形,再移除消失项并更新变化项。
* 不变行保留现有桶;大区域归 overflow 每次查。格式校验失败不开始改表,但不承诺任意运行时异常都回滚。 */
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */
/** 覆盖格+overflow 合并候选,无法安全展开的大查询改扫全部 rows;最后含边界相交精筛。
* 返回新 Set(只读仅是类型),外部改它不影响索引;不排序,不能用迭代次序当绘制层序。 */
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
/** 释放所有可重建空间表,不保留跨场景的区域引用。 */
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
+4 -2
View File
@@ -6,9 +6,11 @@
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。
* 缺 hasPointerCapture 的匹配目标也视为已丢失;此函数不释放 DOM 捕获、不提交/取消 editor 手势。 */
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */
/** setPointerCapture 未抛错则返回 true,不再调用 hasPointerCapture 确认平台状态。
* 平台拒绝/节点卸载等异常统一 false,具体是否继续/取消手势仍由调用方决定。 */
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
+11 -4
View File
@@ -10,7 +10,9 @@ import type {projectCanvas,ProjectionNode} from './projection.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */
* 只输出超出原生矩形的节点完整 paint AABB,不是只输出外溢条带;每帧遍历所有显示 node。
* 单节点协议失败记 unknown 后继续,不包含边/selection/手势状态,也不决定 renderer 最终是否保留元素。
* 不检查 registry 与 projection 的来源身份,调用方须保持同解释上下文;fallback 按矩形外壳解释。 */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{
@@ -36,7 +38,8 @@ const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b=
/** 变换值比较避免无关根发布反复计算 paint 边界。 */
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
* 显式声明后只比较列出的 element.data 顶层字段(不是点分路径),空数组表示声明不依赖 data。
* 依赖列表须随同一 protocol 对象保持不变;遗漏字段/原地修改声明不会被缓存自动推导为新协议。 */
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
const dependencies=protocol.dataDependencies;
@@ -51,14 +54,18 @@ function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
}
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
/** 视图级增量 paint 网格:entries 缓存每个节点的签名及 regions/unknown 结果,index 只存额外范围。
* 不增量维护输入成员表,每次 sync 仍遍历全部 node 并 index.sync 全部 regions;只节省未变几何计算。
* 由 registry 对象/project/canvas 隔离,不额外核 editor/view owner;宿主应给每个视图独立实例。 */
export function createProjectedPaintIndex(){
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
type Projection=ReturnType<typeof projectCanvas>;
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
const entries=new Map<string,Entry>(),index=createPaintIndex();
return Object.freeze({
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */
/** 切 registry/project/canvas 先清表,逐节点比较签名,变化才重新捕获依赖并调用完整单节点算法。
* unknown 结果同样可缓存至签名变化;数据捕获/比较异常可能直接抛出,整轮并非事务回滚。
* query 是同一可变 index 的方法,旧返回对象的 query 会查询后续 sync 的网格;unknown 则是本轮冻结列表。 */
sync(projection:Projection,registry:Registry){
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
+9 -4
View File
@@ -12,14 +12,17 @@ type Projection=ReturnType<typeof projectCanvas>;
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
/** 创建视图专属结构共享器;reuse 会就地改传入 nodes/edges 数组,调用方应提供自有可变派生数组。
* 不深克隆/冻结显示对象;缓存的对象须按不可变约定使用,不能传入业务记录数组或原地修改旧投影。 */
export function createProjectionCache(){
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
/** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
/** 每个显示 ID 须有文档记录;生命周期键只核 incarnation+owner,不另含 component/dataVersion,显示差异由值比较处理。
* 仅 data/style/position 三个嵌套字段单独等价共享,再比较整个显示对象;新表只含本帧项。
* 不查投影 ID 重复、不检查 node/edge 与记录的全部对应语义;这依赖上游合法投影。 */
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
const next=new Map<Id,Retained<T>>();
for(let index=0;index<values.length;index++){
@@ -35,7 +38,8 @@ export function createProjectionCache(){
return next;
}
return Object.freeze({
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */
/** owner/project/canvas 改变先清旧表/数组,再分别 retain 节点和边,逐项引用相同才复用整个旧数组。
* 总返回新的投影外壳,nodes/edges 可沿用旧引用;中途抛错可能已改传入数组,不提供事务回滚。 */
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
@@ -44,7 +48,8 @@ export function createProjectionCache(){
},
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
getNode(id:Id){return nodes.get(id)?.value;},
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
/** 核上下文、incarnation/owner、renderer type、width/height 与 data 等价,决定能否借旧测量。
* 不要求 position/style/整个 node 等价,也不执行 DOM 测量;调用者仍负责应用测量结果的 UI 规则。 */
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
const old=nodes.get(value.id),record=document.elements.get(value.id);
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
+21 -8
View File
@@ -16,7 +16,8 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '..
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
const origin:Transform={x:0,y:0,scale:1,rotation:0};
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */
/** 降级原因/类型/版本及从已知结构派生的把手;节点显示还可保留字符串标题和通用身份/几何。
* 不透传整份未知 data 给旧 renderer,不等于删除原 payload 或保证显示完全不读取任何通用字段。 */
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
@@ -28,7 +29,10 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。
* 已知 data 字段浅展开后由通用显示字段覆盖,record-v1 另借用原 element/transform,不深复制模型记录。
* draggable 只按 known 与 geometry.move 能力判定,缺 renderer 的 fallback 不必自动只读;策略仍由 editor 检查。
* 返回可变派生对象供后续补把手/结构共享,只有 __headlessTarget 单独冻结,不是完整输出深冻结。 */
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
@@ -47,7 +51,9 @@ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,ca
/** Endpoints are already resolved to their displayed node IDs by the caller. */
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
* 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */
* compat-v1 只对 target.id 匹配的边采用预览路径,变到场景后通过 connectionPathValid 才输出。
* record-v1 接原记录/transform,不在此嵌入预览或转换结构路径;原生 renderer 须从自己的受控显示上下文读取预览。
* data.path 从兼容 payload 中剔除,避免残留字段覆盖结构 path;输出仍是派生边,不改真实端点。 */
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
@@ -59,7 +65,10 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */
* 只输出 owner 在当前展开范围的连线,再把隐藏后代端点映射到可见折叠入口。
* 输入须是已验证的文档树/registry;这里查显示根与可选 queries 身份,不重做完整文档校验或检测所有递归环。
* 不做 viewport 裁剪或网络/资源读取;输出全体展开场景,再由 renderer/paint 索引决定屏幕保留。
* 派生容器虽可变,消费者仍应按只读约定使用;transforms 可复用共享 origin/几何缓存值,不应原地改它们。 */
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
@@ -72,7 +81,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
/** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */
/** 按成员稳定顺序深度优先:先投影入口,再用同一份预览几何/单位构造内部 canvas 变换。
* 隐藏分支用栈遍历后代建立 hiddenBy,不生成内部节点/边变换;无 queries 时反复从 canvas 表找子项。 */
const visit = (id: Id, depth: number) => {
const transform = transforms.get(id)!;
for (const element of getMembers(id)) if (element.structure === 'node') {
@@ -91,10 +101,12 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
}
};
visit(canvasId, 0);
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。
// 节点/hiddenBy 全部就绪后再投影边,避免端点代理依赖节点是否已遍历。
// queries 路径汇集展开 owner 的边再按文档层序排,无 queries 则扫描 element 表;模型归属始终不变。
const edges: ProjectionEdge[] = [];
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
/** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */
/** 端点节点可见则保留 ID,否则按其 owner 的 hiddenBy 找折叠代理。
* 缺显示端点或两端同代理时仅省略本帧边,不从模型删除,也不改为自由端点。 */
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
if (!source || !target || source === target) continue;
@@ -121,7 +133,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
}
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。
// 成功遍历结束才 end 清未访问几何项;不是 finally,异常时由下次完整轮次或视图 clear 清理缓存。
// document 是原不可变引用,nodes/edges/transforms 是本次派生容器,不发布 editor 修改。
cache?.end();
return { document,canvasId,nodes, edges, transforms };
}
+1
View File
@@ -1,6 +1,7 @@
/**
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
* 重导出不自动挂 Provider、attach view 或创建 engine;ReactFlowProjection* 只是结构类型,不运行数据转换。
*/
"use client";
/** Explicit framework boundary; consumers install the declared optional peers. */
+9 -4
View File
@@ -15,14 +15,17 @@ export type ReactRenderers={readonly catalog:RendererCatalog;readonly definition
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */
/** $$typeof 为 react.memo 则原样返回;其它受支持组件按原引用弱缓存 React.memo 包装。
* 不调用 render、不解析 lazy 内容,也不缓存组件状态;包装身份稳定不保证所有 props 变化都免渲染。 */
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
}
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */
* 同一 structure 内 rendererId 可别名共用,但普通/预览组件引用必须一致;node/edge 类型表彼此独立。
* 标记对象只做结构识别,不证明组件安全、纯净或必能成功渲染;安装来源仍须受信。
* 每次调用新建目录/表/外壳,只有组件 memo 包装共享;冻结注册表不冻结函数闭包或 React 内部状态。 */
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
@@ -31,11 +34,13 @@ export function createReactRenderers(definitions:readonly ReactRendererDefinitio
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
// 如提供模型 registry,就要求精确 componentId/dataVersion/structure 匹配,不能靠显示组件掩盖 schema 不兼容。
// 可选 registry 只核 componentId/dataVersion/structure 精确存在,不执行某个元素的数据 schema/迁移。
// 未提供 registry 也可装配纯显示目录,真正投影元素时仍需模型解释与 fallback 判断。
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。
// 定义保留原 render/preview 引用,类型表存编译后的 memo 引用;不混淆这两层身份。
// 缺 preview 则不创建对应 previewTypes 项,工厂不自动用正式组件充当预览;宿主决定降级路径。
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
}
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
+31 -13
View File
@@ -22,12 +22,15 @@ export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
const CanvasContext=createContext<CanvasBinding|null>(null);
/** Borrows the engine and an already attached view. Unmount never closes the project. */
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */
/** value 只按 engine/viewId 引用 memo 并冻结外壳,children 使用 React Context;不是项目实例拥有者。
* 不 attach/detach view、不创建/销毁 engine;若使用交互 view,应由外层宿主先装配,卸载 Provider 也不关闭项目。 */
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */
* selector 或等价函数换引用时重建派生 store,但可按 committed.current 复用等价结果,避免无意义选中值换身份。
* effect 才推进已提交基线,不在 render 中认领提交;source/selector/isEqual 须遵守同步不可变读取合同。
* getServerSnapshot 也使用同一 store.getSnapshot,不创建独立服务端模型,宿主须提供可稳定读取的绑定。 */
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
const committed=useRef<{value:T}|null>(null);
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
@@ -47,7 +50,9 @@ function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
export function useElement(ref:Id|Target):ElementView;
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */
/** 字符串 ID 跟随当前生命周期;Target 仅在 present 且 incarnation 不同返回 stale,absent/unloaded 原样保留。
* 每个 engine 共用按 ID 源;hook 再派生代次/selector 结果,默认完整视图 equal,自定义选择默认 Object.is。
* stale 带当前元素供展示,但不自动把旧 Target 升级成新代次的写入权限。 */
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
@@ -55,7 +60,9 @@ export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEq
}
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */
/** 按 engine/id/incarnation memo 动作外壳并捕获冻结 Target,之后改传入 target 对象不影响旧闭包。
* rename/setData/resize/remove/moveTo 都只是普通 command;resize 接 Partial<Geometry>,不只限 width/height。
* 调用时 editor 再核代次/策略/事务,不乐观修改 React 局部文档,也不在动作闭包里排网络请求。 */
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
@@ -69,16 +76,20 @@ const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSo
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
/** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。
* 它不是跨两源的独立事务,合法性由使用方的目标代次校验保证。 */
* 顺序读两源,不是跨源事务或原子快照锁;目标合法性仍由 hooks/命令校验。
* 正常退订依次 a()/b(),没有 finally 隔离,宿主取消端口须不抛错;右侧注册失败时尽量撤回左侧。 */
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
let previous:readonly[A,B]|undefined;
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
}
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */
/** 要求 Provider 有非空 viewId;这里只补绑定形状,不在此 attach 或验证该视图此刻仍注册。
* session 操作入口继续核视图/手势生命周期,不能凭这个返回对象绕过所有权检查。 */
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */
/** 将 Error 风格异常的 code/targets 转为 Availability,缺 code 用 binding_failed。
* 不归一化任意 thrown 值;null/undefined 不符合这里直接读属性的错误合同。 */
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */
/** 成对订阅元素查询和 view/element 模式;缺元素或旧代次读成 null,不把旧元素的编辑模式套给新元素。
* setMode 调用时再核当前 incarnation,然后由 sessions.mode 检查视图;返回可用性,不直接编辑内容/history。 */
export function useMode(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
@@ -87,7 +98,9 @@ export function useMode(target:Target){
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
}
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */
* controls 内 lease 仅是本 hook 捕获的数字 token,不是另一份手势事实;真实 active/preview 在 sessions。
* effect 清理先核 token/view/active,仅取消自己取得的手势;旧组件卸载不能取消后来另一视图的新手势。
* begin/update/commit 沿用 session 的结果或异常;拥有手势后进入 commit 的 try/finally 才保证清 lease,未拥有则直接拒绝。 */
export function useGestureBindings(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
@@ -106,7 +119,8 @@ export function useGestureBindings(target:Target){
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
/** 单操作诊断读取,返回逻辑操作及关联冻结请求,不提供直接改任务状态的方法。 */
/** 每 engine/id memo 派生源,useSyncExternalStore 读取逻辑操作和关联请求;缺 ID 返回 undefined。
* 这是诊断订阅,不轮询服务、不授予直接修改 phase/request 的接口。 */
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
type Durability=ReturnType<ProjectEngine['getDurability']>;
@@ -117,7 +131,8 @@ const inactiveOwner=(value:Durability)=>value.closing||value.closed;
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。
* 计数与 durability 来自两条订阅,不建立新的事务根;status 是显示优先级归纳,synced 不证明远端生成已结束。 */
export function useSyncStatus(canvasId?:Id){
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
@@ -125,7 +140,10 @@ export function useSyncStatus(canvasId?:Id){
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
}
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */
* 异步返回核 abort,渲染再核 state.engine/id/key;metadata 与句柄分别来自根和 effect,不另存原资源字节。
* key 只含上传 operationId/purge、binding.authority/digest,普通元信息变化不全都重开显示请求。
* attempt 只触发 effect 重试,不在返回 state 的身份键中;同来源 retry 的 loading 状态由 effect 启动时设置。
* ready 表示 URL 租约已取得,不等于图片 DOM 已解码/绘制或上传/同步完成。 */
export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
@@ -134,7 +152,7 @@ export function useAsset(id:Id){
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */
/** retry 仅增本 hook 的尝试序号,effect 清旧租约再取新显示句柄;不调用 engine.retry 或重新上传。 */
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
}
+2 -1
View File
@@ -13,5 +13,6 @@ export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCo
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
/** Trusted installed renderers submit normal validated commands, never write a second model. */
/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */
/** 读取最近 Provider 的命令对象,缺失就同步抛错;hook 不缓存命令、不建立订阅或另建 editor。
* Provider 须注入 editor 的受检门面;Context 本身不验证任意自定义函数是否真的经过发布边界。 */
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
+4 -2
View File
@@ -11,7 +11,8 @@ export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];
/** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
/** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。
* 工厂不加载代码、不自动迁移 dataVersion,也不把高版本当成兼容旧版本。 */
* 工厂按声明顺序重建标准标量字段/默认 projection,未知扩展字段不复制,Map 键是精确三元组。
* 不加载 React/插件代码、不执行 schema 校验、不自动迁移版本;resolve 不授予编辑或网络权限。 */
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
const entries=new Map<string,RendererDescriptor>();
for(const value of definitions){
@@ -31,5 +32,6 @@ export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinit
/** Installed renderers without a reference declaration wait for full context. */
/** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。
* 保留 definitions 供装配检查,但 resolve 才决定当前是否允许显示实现。 */
* 保留原 catalog.definitions 供装配检查,resolve 每次先查 registry 中 component 是否有 copy 声明。
* 这仅是局部加载门禁,不验证声明覆盖所有引用、schema 或源记录版本,也不证明依赖已加载;精确 renderer 匹配仍由 catalog 执行。 */
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
+12 -5
View File
@@ -14,7 +14,9 @@ export type ResourceViewSource = { readonly resourceId: Id; readonly url: string
* unchanged nodes, publish each ready source independently, and fence late work.
*/
/** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。
* sync 输入是完整目标集合,retry 只重建失败项,dispose 幂等释放所有在途/已完成句柄。 */
* sync 输入是完整目标集合,retry 只重建失败项,dispose 请求中止在途并归还已有句柄。
* 工厂不立即 resolve;每个元素一次独立尝试,同资源的多个元素不在此合并(底层缓存可共享读取)。
* resolve 须返回合法 DisplayHandle;本模块不校验 URL/下载权限或图片内容,回调错误仅报告。 */
export function createResourceView(options: {
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
@@ -24,13 +26,16 @@ export function createResourceView(options: {
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
/** 错误展示抛错不影响租约释放或其它图片的加载。 */
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
/** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */
/** 每次重建 ready 元素→{resourceId,url} 的新 Map,另计 failed;pending 不计失败也不带空 URL。
* Map/条目未运行时冻结,但不暴露内部 record/handle;onChange 抛错被 report 隔离,不撤销已采纳句柄。 */
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
/** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
/** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
/** 完整验证元素/资源 ID 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */
/** 先验证并捕获完整目标映射(元素 ID 唯一,资源 ID 可重复),再删除/换源/补新尝试。
* 未变化项包括 failed 都保留,因此同集合 sync 不隐式重试;显式 retry 才重建失败项。
* 变化时先发布当前 ready 集合,各 resolve 独立完成再各自发布,不等全组 Promise.all。 */
function sync(next: readonly (readonly [Id, Id])[]) {
if (disposed) throw fault('resource_view_disposed');
const desired = new Map<Id, Id>();
@@ -60,9 +65,11 @@ export function createResourceView(options: {
}
return Object.freeze({
sync,
/** 移除失败记录后按 targets 重新 sync;成功项不重新取 URL。 */
/** 先撤掉失败尝试并释放,再用最近 targets 重新 sync;ready/pending 项继续复用。
* 没有失败项也不额外发布,不重新授权所有成功资源或重建业务元素。 */
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
/** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */
/** 同步标 disposed、请求 abort 并 release 已有句柄,清目标/记录后发布空集合。
* 不等待 resolve Promise 退出;迟到成功由记录身份/ disposed 检查归还,后续 sync/retry 明确拒绝。 */
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
});
}
+13 -6
View File
@@ -20,7 +20,9 @@ export type SyncCounts={readonly pending:number;readonly blocked:number;readonly
type Dependencies=readonly(object|boolean|undefined)[];
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
/** 装配 operation/asset/counts 三类通道,首次订阅连上游,最后一个通道离开后断开。 */
/** 装配 operation/asset/counts 三组:组内按 key 共享桶,所有活动组共用一个上游订阅。
* 只按所声明表对象身份/布尔值检测依赖,要求上游不可变;原地改 Map 成员不会自动生成新依赖戳。
* factories/getSnapshot 不启动网络或修改业务状态,选中记录仍借用 Root 中的不可变事实。 */
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
// 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
@@ -33,7 +35,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
for(const group of active)group.update(root,notifications);
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
/** 为共享依赖的一类按键查询创建桶。依赖不变跳过计算,失败桶即使签名未变仍可重新尝试。 */
/** 一组依赖对应多个 key 桶;observedSignature 决定路由,failed 键可在戳未变时再试。
* cachedSignature 只说明读取缓存对应哪个依赖戳,不能替代 observed 的通知基线。 */
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
// Direct reads may precede notification, so read caches cannot replace observed baselines.
// cached 服务即时读取,observed 是上次通知基线;两者不能合并,否则渲染提前读取会漏通知。
@@ -44,7 +47,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
return bucket.cached;
}
// 只更新有订阅的键;失败时仍通知使用方,让其读取/错误边界重新解释,不造一个成功值。
// 戳变化扫描全部已订阅 key,否则只扫描 failed;readCached 失败不会推进该桶缓存戳。
// 失败/失败后恢复都通知,cached/observed 不造一个假成功值;其它正常等价桶不通知。
const owner:Group={update(root,notifications){
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
for(const key of all?buckets.keys():[...failed]){
@@ -67,7 +71,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
const listener=()=>notify();bucket.listeners.add(listener);
/** 撤销本订阅;空桶和空组立即删除,失败键也同时清理。 */
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
// 首次注册上游失败撤回桶;正常退订只有最后一个活动组离开才停止共享监听。
// 首次注册上游失败撤回本次 listener/空桶;各订阅包装回调身份,传同一个 notify 也可独立退订。
// 最后活动组离开才 stop 上游;stop 抛错不兜底重置,宿主取消端口须可靠完成。
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
}});
@@ -80,7 +85,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
/** 同步计数只取操作表、请求表和是否 loading,不因文档几何或历史变化全表重数。 */
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
// 同一依赖戳一次扫描得出项目及各画布计数,多个 canvas hook 共享结果。
// 同一依赖戳只扫描一次得到项目/各 canvas 计数,空 canvas 共享零计数;loading 是项目级布尔值。
// canvas 归属沿用 intent.canvasIds,不在这里从当前 owner 推导;上传等空参与者任务只进入项目计数。
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
const stamp=signature(countDependencies(root));
if(stamp!==countStamp){
@@ -92,7 +98,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
}
// 请求只统计仍关联未决操作的项。无 retryAt 的请求错误计 blocked,已发出但结果未知计 unknown。
// 请求仅在仍关联未决操作时计数,canvas 集合取这些操作参与者的并集,不直接用 request.canvasIds。
// 请求 blocked 可与操作 blocked 叠加,unknown 也可同时非零;它们不是互斥任务/节点数量。
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
+14 -6
View File
@@ -11,13 +11,16 @@ import type {RendererCatalog} from './renderers.ts';
/** 与完整 projectCanvas 保持同一结果结构,避免增量路径自行定义另一套显示规则。 */
type Projection=ReturnType<typeof projectCanvas>;
/** 连续发布的可信写集合;from/to 必须恰好连接旧投影与新文档,topology 表示禁止局部修补。 */
/** from/to 按 Document 对象身份连接连续发布,ids 是宿主提供的完整写集合;这里不重算其覆盖证明。
* topology=true 禁用局部修补,Set 可由 scene coordinator 累加,不是持久 Change 日志或业务事件队列。 */
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
/** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
/** 真正影响场景内容的输入;viewport/tool/mode 等状态由别的显示通道处理。 */
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
/** 为上一帧建立 ID→数组位置、分区入口及深度索引;发现降级节点/边后禁用快速修补。 */
/** 建上一帧 ID→数组下标、入口 elementId→子 canvasId、显示 canvas 深度索引。
* 深度沿 transforms 迭代顺序累计,依赖完整投影按父先子后产出;不在这里重新排序/校验整棵树。
* unsupported 标记当前已有 fallback node/unsupported edge,供 patchScene 直接回退。 */
export function indexScene(projection:Projection){
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
@@ -26,14 +29,17 @@ export function indexScene(projection:Projection){
}
/** Reuse existing projections for checked field edits and ordinary node insertions. */
/** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。
* 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 */
* 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。
* 调用者须先核 owner/registry/catalog/canvas 兼容,layout 对应 base,queries 对应 next.document;本函数不重复全部护栏。
* undefined 表示本次不适合快路径,不是业务拒绝;投影器/外部服务抛错则直接传播,没有错误吞并或业务发布。 */
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
const {document,selection,preview,registry,catalog,canvasId}=next;
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
if(layout.unsupported||document.projectId!==base.document.projectId)return;
const dirty=new Set<Id>(),created=new Set<Id>();
if(document!==base.document){
// 可信连续 changes 可直接路由 ID;拓扑提示则立刻放弃。无提示时需要逐项核对表顺序及内容。
// 匹配 from/to 的 hint 按完整写集合合同直接取 ids,不额外全表证明“没有遗漏其它修改”。
// 缺可信 hint 才比较 canvas 表及 element 表大小/顺序/内容;这条扫描路径本身不支持插入或删除。
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
else{
// Canonical replacement and missed publications carry no trustworthy write set.
@@ -59,7 +65,8 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
// 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
}
// 懒复制:未受影响的数组与节点/边对象沿用原引用,新对象仍调用完整路径共用的投影函数。
// 先通过脏项形状检查再懒复制受影响数组,未变对象仍共享;所有新节点/边调用完整路径共用投影函数。
// base.transforms 只在拓扑兼容时复用;未显示 canvas 的普通变更可以只更新返回 document 而不增加显示节点。
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
for(const id of dirty){const element=document.elements.get(id)!;
if(element.structure==='node'){
@@ -81,7 +88,8 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
// without reconstructing unaffected nodes, transforms or edge projections.
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
const ordered:Projection['nodes']=[];let complete=true;
/** 只重排节点引用,不重算未变化节点;任一所需投影缺失就放弃这份候选。 */
/** 按 owner 成员顺序深度优先重排节点引用,只进已在 transforms 显示的子 canvas。
* 缺任一所需投影就令 complete=false 回退;依赖已验证树,无独立循环/递归深度预算。 */
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
visit(canvasId);if(!complete)return;nodes=ordered;
}
+11 -4
View File
@@ -12,13 +12,17 @@ import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './sce
import type {RendererCatalog} from './renderers.ts';
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
/** 每个视图独立创建;advance 接 editor 发布,project 接当前显示输入,clear 释放全部派生缓存。 */
/** 每个视图一份 previous/value/layout/pending 与几何缓存;advance 只积写集合,project 才按需算场景。
* 缓存借用不可变文档/选择/预览/registry/catalog 身份,不能在原对象中改字段后期待自动发现失效。
* 实例不订阅 editor、不请求帧,宿主负责接线 advance/project 与视图 clear 生命周期。 */
export function createSceneProjection(){
let geometry=createGeometryProjectionCache();
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
return Object.freeze({
/** 累积从上次投影文档到当前文档的连续写集合;丢发布、无 changes 的替换或大范围改动退回全量。 */
/** 累积上次投影文档→当前文档的连续发布链;同一文档对象直接忽略,未有场景时无需积累。
* 断链只在 previous.document===before 时能重起;空 changes 丢提示,canvas 改动/脏 ID>16384 只标 topology。
* 这里只影响快路径选择,不重放 Change、不写 doc,也不保证输入 changes 本身语义完整。 */
advance(before:Document,after:Document,changes:readonly Change[]){
if(before===after)return;
if(!previous||previous.document===after){pending=undefined;return;}
@@ -31,7 +35,9 @@ export function createSceneProjection(){
if(pending.topology)pending.ids.clear();
},
/** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。
* 可兼容才尝试 patch,失败正常回退 projectCanvas;查询必须与同一份文档/registry 绑定。 */
* 可兼容才尝试 patch,undefined 正常回退 projectCanvas;异常不自动吞成另一份成功结果。
* queries 先查 document/registry 身份;其对象身份不在场景键中,单独更换 queries 不触发重算。
* 空 preview 规范为 undefined,viewport/tool/mode 不在键中;传入的 sessions 是捕获帧,不读取更新的 live 会话。 */
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
@@ -43,7 +49,8 @@ export function createSceneProjection(){
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
// 全量重建或节点数量改变才重建布局索引;成功采用结果后消费并清掉 changes 提示。
// 全量结果或节点数变化才重建 layout,其余兼容 patch 沿用数组下标/拓扑索引。
// 成功后一次推进 previous/value 并清 pending;异常不写这三项,但本轮几何缓存可能已有计算,不承诺整缓存回滚。
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
previous=next;value=result;pending=undefined;return result;
},
+7 -3
View File
@@ -5,15 +5,19 @@
/** One immutable source, one cached selection; no second document or operation state. */
/** 最小快照/订阅协议,可由 editor、session 或派生源实现,订阅返回释放函数。 */
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
/** 用 selector 与等价判断派生稳定快照;等价的新值沿用旧引用,避免 UI 的无效刷新。 */
/** 一个 store 共享一份最近源快照/selection 缓存,每个订阅另存 observed 引用。
* selector/isEqual 须同步且适合不可变源;不 clone/freeze 选中值,不修复上游原地修改后身份不变的问题。 */
export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){
let initialized=false,snapshot:S,selection:T;
/** 根未变则跳过计算;根变但结果等价也保持 selection 引用,只更新已观察的根身份。 */
/** 根身份相同则跳过 selector;新根等价则沿用旧 selection,仅推进已观察源身份。
* selector/isEqual 抛错发生在缓存赋值前,原缓存保留,getSnapshot 仍向调用者抛错。 */
function getSnapshot():T{
const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection;
const next=selector(current),same=initialized&&isEqual(selection,next);
snapshot=current;if(!same)selection=next;initialized=true;return selection;
}
// 每个订阅单独跟踪 observed;计算失败仍通知,让使用方在自己的读取/错误边界重新处理。
// subscribe 先同步读取初值(失败则尚未接上游);每个 observer 独立比较 selection 身份。
// 上游通知时计算失败仍 notify,供使用方重读/错误边界处理;不把 notify 的异常吞成成功。
// 直接返回上游 stop,无额外 dispose:退订幂等性、异常与通知时序沿用源合同。
return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}});
}
+4 -1
View File
@@ -6,7 +6,9 @@ import type {SessionSnapshot} from '../canvas-core/gestures.ts';
import type {ExternalSource} from './selectors.ts';
import {createFrameQueue,type FrameClock} from './frame-queue.ts';
/** One subscription owns one disposable frame queue. Reads always see current sessions. */
/** 对比前后会话引用生成 view/element 脏 key;退订时同步撤销帧队列。 */
/** 每个订阅保留上一份 session 引用,按不可变 view/gesture 身份发现失效,任意 dirty 最终只触发 notify()。
* key 集合是帧内去重依据,不向消费者发布逐元素差分;getSnapshot 直接借用源方法,须不依赖 this 绑定。
* 此包装不使用 immediate 标志,也不对上游 subscribe/stop 抛错作 finally 清理,宿主须保证正常订阅生命周期。 */
export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
@@ -17,6 +19,7 @@ export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,
if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`);
// 连线手势与普通几何手势分别跟踪,改变或取消时都让旧/新目标失效。
if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`);
// 先推进 previous 再排队,因此一帧内多次变化相对每次上游快照计算,不只比较帧起点与终点。
previous=next;queue.invalidate(dirty);
});return()=>{stop();queue.dispose();};
}});
+16 -7
View File
@@ -10,7 +10,9 @@ import type {ExternalSource} from './selectors.ts';
/** preview 可被所有匹配代次的视图读取,active 则只标记拥有该手势的视图。 */
export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean};
/** Derived view/target subscriptions. Values retain no document or full per-hook session. */
/** 建立按视图模式和元素手势分离的订阅通道,比较派生值后才通知观察者。 */
/** 建立按视图模式和几何手势分离的通道;只追踪 modes/gesture,不把 selection/viewport/connectionGesture 混入。
* modes 是 viewId→elementId→观察器桶,gestures 是 elementId→观察器桶,同一上游变化先选候选再比值。
* 有观察器时 previous 持有一份上游快照引用供比较,不复制完整 session;最后退订后释放该引用。 */
export function createSessionSources(source:ExternalSource<SessionSnapshot>){
// update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
@@ -18,9 +20,11 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
// 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined;
/** 首次遇到一组目标时建索引;相同 ID 的新 incarnation 不会匹配旧手势。 */
/** 按 targets 数组身份缓存 elementId→incarnation,要求上游不原地改 targets。
* 合法会话应保证同组 ID 唯一;Map 对重复 ID 留最后一项,此 helper 不另验目标表形状。 */
function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;}
/** 收集可能受影响的观察器,全部 update 完再逐个 notify;一个监听器失败也不饿死其余监听器。 */
/** 在 changed 入口取得同一份 next,先推进 previous,再用 Set 合并所有候选,避免观察器重复更新。
* 先更新全部候选 observed 后 notify;各 notify 异常暂存,余者仍执行,最后重抛第一个异常(包括假值)。 */
function changed(){
if(!previous)return;const before=previous,next=source.getSnapshot();previous=next;
const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
@@ -40,7 +44,9 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer);
let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
/** 一个派生通道的稳定读取和订阅装配。读取失败通过通知触发使用方重读,不把错误吞成旧成功值。 */
/** 每个 channel 独享 getSnapshot 的派生值缓存;每个订阅另存 observed,二者不靠回调共享可变业务数据。
* equal 深比较派生 JSON 风格值,等价时保留旧引用;选中 preview 仍借用原不可变 Geometry,不做深克隆。
* observer.read 失败会标记失败并通知,后续成功也通知一次恢复;直接 getSnapshot 的读取异常仍向外抛出。 */
function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
let initialized=false,value:T;
return Object.freeze({
@@ -51,18 +57,21 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
const remove=register(observer);
// 第一个观察器才连上游;注册失败撤回本次桶,防止留下无人管理的观察器。
if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}}
// 单次退订幂等;所有桶清空后释放全局上游订阅及上一份会话快照。
// 退订 token 先置 inactive,remove 清桶;最后一桶消失才 stop 上游并清 previous。
// stop 异常不在这里捕获,状态重置不保证继续执行;正常宿主清理端口须不抛错。
let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}};
},
});
}
return Object.freeze({
/** 派生某视图中单元素的编辑模式;视图/模式缺失时返回 null。 */
/** 捕获 viewId/id 两个标量,按 view→element 桶登记观察器;视图/模式缺失返回 null。
* 调用 mode 只创建派生通道,首次 subscribe 才注册桶和必要的上游监听。 */
mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{
let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer);
return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);};
});},
/** 按目标 ID+代次读取预览;同元素跨视图可见预览,但只有原 viewId 拥有 active。 */
/** 入口捕获 target.id/incarnation,之后改调用者 target 不影响本通道;订阅按 ID 分桶,读值再匹配代次。
* 匹配元素跨视图可见 preview,只有 gesture.viewId 对应视图 active;没有 preview 不代表不在手势 targets 中。 */
gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{
const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation;
return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId};
+2 -1
View File
@@ -4,5 +4,6 @@
import type {Rect} from '../canvas-core/geometry.ts';
/** Positive-area native node visibility. Degenerate nodes are retained for measurement. */
/** 正面积矩形要求与视口有正面积交叠,只有边界接触不算可见。
* 宽/高非正的退化节点保留,让 renderer 有机会测量它,而不是提前裁掉。 */
* 宽/高非正的节点直接保留,即使远离视口,让 renderer 有机会测量;负数也走同一分支,并不在此修正。
* 调用方须提供同一坐标系中的轴对齐矩形;此函数不处理旋转、zoom 或 NaN/Infinity 合法性。 */
export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}
+2
View File
@@ -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 };
// 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。
+3
View File
@@ -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 {
+3
View File
@@ -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}:{}) };
}
+3
View File
@@ -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;
+3
View File
@@ -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);
+4 -1
View File
@@ -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("图标加载失败");
+2
View File
@@ -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) };
}
+5 -1
View File
@@ -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};
+3 -1
View File
@@ -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中安装的中断处理器。 */
+17 -7
View File
@@ -11,13 +11,17 @@ import { runTerminal, type RunPort } from './runs.ts';
import type { AssetPort } from './resources.ts';
/** Stage acknowledgements only. The caller must validate a complete fresh read and durably publish it. */
/** 在私有表上解释已保存回执,返回 staged 快照与 fresh read 断言;缺证据/解释器即整体拒绝。 */
/** 取全部非 resolved 请求,要求已有 outcome=applied 且有保存回执,再按最早逻辑 sequence 解释。
* 只构造 ledger/runs/resources/conflicts 候选,document/history/acknowledged/versions 仍沿用原 snapshot。
* 没有待处理请求时也可返回空 stage;不会自己读取 canonical、重放 pending、发布 Root 或等待持久化。
* 依赖输入账本及 ports 的验证/纯解释合同,表复制不隔离恶意 adapter 对传入记录的修改;now 合法性也由宿主保证。 */
export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:PersistencePort;runs?:RunPort;assets?:AssetPort;now:number}) {
const waiting=[...snapshot.runtime.ledger.requests.values()].filter(request=>request.phase!=='resolved');
// 只要还有 accepted/unresolved,就不能把整组当成确定已执行恢复,也不能新建替代请求重发。
const unknown=waiting.filter(request=>request.outcome!=='applied');
if(unknown.length)throw fault('recovery_waiting_for_receipts',unknown.map(request=>request.id));
/** 同一请求可能包含多个意图,以最早逻辑 sequence 排序,保证 run 受理先于其控制动作解释。 */
/** 以请求成员中最小 sequence 排序;成员缺失会取 Infinity,不在此独立证明账本成员/依赖合法。
* 合法顺序让 run 受理先建立候选记录,后续控制动作才能定位它;不是按回执到达时间解释。 */
const sequence=(request:FrozenRequest)=>request.operationIds.reduce((result,id)=>Math.min(result,snapshot.runtime.ledger.operations.get(id)?.intent.sequence??Infinity),Infinity);
waiting.sort((a,b)=>sequence(a)-sequence(b));
// 新建候选账本和资源/run 表,后面的任何校验失败不会污染原 snapshot。
@@ -37,7 +41,8 @@ export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:
}else if(['run.start','run.cancel','run.apply'].includes(request.kind??'')){
if(!ports.runs?.verifyApplied)throw fault('run_receipt_interpreter_unavailable',[request.id]);
const verified=ports.runs.verifyApplied(request,receipt);proof=verified.model;
// 受理必须绑定原 intent/request/board/target;已有 run 也必须具有相同身份和计划。
// 新受理核对原 intent/request/board/target,已有同 runId 时再比较 request/operation/plan。
// 已有记录保留其当前进度,不用旧 admission 覆盖;完整 run 形状/服务身份还依赖 verifyApplied 与外围校验。
if(request.kind==='run.start'){
const run=verified.admission,intent=snapshot.runtime.ledger.operations.get(request.operationIds[0])?.intent;
if(!run||verified.action||intent?.operationType!=='run.start'||run.operationId!==intent.id||run.requestId!==request.id||run.boardId!==intent.run.boardId||!equal(run.target,intent.run.target))throw fault('run_identity_mismatch');
@@ -50,23 +55,28 @@ export function stageAppliedRecovery(snapshot:EngineSnapshot,ports:{persistence:
if(request.kind==='run.cancel'){
if(!action.runStatus||action.outputId||runTerminal(run.status)&&runTerminal(action.runStatus)&&run.status!==action.runStatus)throw fault('run_status_conflict');
}else if(!action.outputId||action.runStatus)throw fault('run_identity_mismatch');
// 控制 epoch 递增阻止旧 poll 盖回新状态;标 hasMore/nextPollAt 让任务按原 ID 继续追尾。
// 控制动作推进 epoch 并去重登记已采用 output ID,清旧 pendingPage/错误,让原 run ID 从 now 继续追尾。
// 这里只暂存状态,不开始 poll;clock/epoch 范围与当前身份仍需宿主最终采纳时验证。
runs.set(run.id,freeze({...run,controlEpoch:(run.controlEpoch??0)+1,...(action.runStatus?{status:action.runStatus}:{}),...(action.outputId?{appliedOutputIds:[...new Set([...(run.appliedOutputIds??[]),action.outputId])]}:{}),hasMore:true,blocked:false,error:undefined,pendingPage:undefined,nextPollAt:ports.now}));runIds.add(run.id);
}
}else{
if(!ports.persistence.verifyApplied)throw fault('document_receipt_interpreter_unavailable',[request.id]);
proof=ports.persistence.verifyApplied(request,receipt);
}
// 各请求有不同写版本范围:start 只 owner,cancel/upload 无文档写,普通/apply 要完整参与者。
// 证明版本键必须恰好覆盖写范围:start 只 owner,cancel/upload 空,普通/apply 为 request.canvasIds。
// 这里只要求 proof 版本是安全整数且高于原 expected,不独自要求恰好 +1;精确协议规则由解释器保证。
const writes=request.kind==='run.start'?[request.ownerCanvasId!]:request.kind==='run.cancel'||request.kind==='asset.upload'?[]:request.canvasIds;
if(writes.length!==(proof?.versions.size??0)||writes.some(id=>!proof?.versions.has(id)))throw fault('receipt_coverage_mismatch',[request.id]);
if(proof){for(const [canvas,version]of proof.versions)if(!Number.isSafeInteger(version)||version<=request.expectedVersions[canvas])throw fault('receipt_version_mismatch',[canvas]);proofs.push(proof);}
// 仅在候选账本标已协调 applied;真正采用仍需外层 fresh read 校验和持久化。
// result 默认按 reconciled applied 归约候选账本并移除此 request 的冲突,原账本/原冻结 body 仍不变。
// staged 未通过 fresh read 断言前不可直接发布,也不因这一步就释放真实 writer 或开始下一请求。
ledger.result(request.id,'applied');conflicts.delete(request.id);
}
const staged:EngineSnapshot=Object.freeze({...snapshot,runtime:Object.freeze({...snapshot.runtime,ledger:ledger.getSnapshot(),runs:readMap(runs),resources:readMap(resources),conflicts:readMap(conflicts)})});
return Object.freeze({snapshot:staged,requestIds:Object.freeze(waiting.map(request=>request.id)),runIds:Object.freeze([...runIds]),
/** fresh read 版本不得落后于任何执行证明,并逐个执行协议专用内容断言。 */
/** 核对 fresh 版本至少覆盖各 proof 水位,再逐个调用协议内容断言;不拿旧回执模型覆盖 fresh。
* 不在此调用 validateCanonicalRead/validateDocument 或验证读取来源,engine 须先完成这些检查。
* proof Map/回调按端口不可变纯证据合同保留;断言成功也不是持久化完成回执。 */
assertCanonical(canonical:CanonicalProposal){for(const proof of proofs){for(const [canvas,version]of proof.versions)if((canonical.versions.get(canvas)??-1)<version)throw fault('recovery_read_behind_receipt',[canvas]);proof.assertCanonical(canonical);}},
});
}
+34 -14
View File
@@ -2,6 +2,7 @@
* 共享原图的 IndexedDB 实现:asset-depot 存身份/退休事实,asset-bytes 按摘要共享 Blob,
* asset-pins 按项目存与 Root 版本绑定的完整保留清单。所有 execute 都经过主存储的 leader/fence 检查。
* 回收只清本地共享字节,保留身份;同摘要还有其它活身份时不能删实际字节。
* 本模块不创建/提交事务:写入辅助函数借用外层事务,read/collect 借用注入的 execute;错误由其统一 abort。
*/
import {equal,fault,freeze} from '../canvas-core/values.ts';
import {sharedAssetRef,validateSharedAsset,type AssetDepotPort,type DepotCut,type DepotEntry,type SharedAsset} from './asset-depot.ts';
@@ -24,23 +25,31 @@ function authority(value:string){if(typeof value!=='string'||!value||value.lengt
/** 读取身份记录时验证 ref 与键一致、退休时钟合法,损坏数据不能被回收过程忽略。 */
function validateFact(row:Fact,scopeAuthority:string,id:string){validateSharedAsset(row?.ref);if(row.ref.authority!==scopeAuthority||row.ref.asset.id!==id)throw fault('corrupt_asset_depot');for(const n of [row.unreferencedAt,row.purgedAt])if(n!==undefined&&(!Number.isSafeInteger(n)||n<0))throw fault('corrupt_asset_depot');if(row.purgedAt!==undefined&&(row.unreferencedAt===undefined||row.purgedAt<row.unreferencedAt))throw fault('corrupt_asset_depot');}
/** Detach descriptors before awaiting crypto. Blob data is immutable and never JSON cloned. */
/** 第一个 await 前捕获/冻结 descriptors,校验 pin/写入唯一性;Blob 不做 JSON clone。 */
/** 同步重建 pin 数组与标准引用字段,让外层能在第一个 await 前捕获 cut;Blob 沿用不可变字节对象。
* pin ID 与 write ID 分别去重,不要求两表完全相等,也不证明 complete 声明覆盖全部 Root 引用。
* sharedAssetRef 保留 asset 扩展属性的引用,冻结不等于任意外来对象的递归深复制。 */
export function detachDepotCut(cut:DepotCut):DepotCut{
if(!cut||cut.version!==1||typeof cut.complete!=='boolean'||!Array.isArray(cut.pins)||!Array.isArray(cut.writes))throw fault('invalid_depot_cut');authority(cut.authority);
// seen 只检查资源 ID 唯一;每条 owner 列表还要非空、非重复,再复制冻结以脱离可变数组。
const seen=new Set<string>(),pins=cut.pins.map(row=>{if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||seen.has(row[0])||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner)||new Set(row[1]).size!==row[1].length)throw fault('invalid_depot_cut');seen.add(row[0]);return Object.freeze([row[0],Object.freeze([...row[1]])] as const);});
// write 可仅含引用以复用已存字节;提供 Blob 时这里只查类型,长度/摘要留给事务外 verifyDepotBytes。
const written=new Set<string>(),writes=cut.writes.map(row=>{validateSharedAsset(row.ref);if(row.ref.authority!==cut.authority||written.has(row.ref.asset.id)||row.bytes!==undefined&&!(row.bytes instanceof Blob))throw fault('invalid_depot_cut');written.add(row.ref.asset.id);return Object.freeze({ref:sharedAssetRef(row.ref),...(row.bytes?{bytes:row.bytes}:{})});});
return Object.freeze({version:1,authority:cut.authority,complete:cut.complete,pins:Object.freeze(pins),writes:Object.freeze(writes)});
}
/** 在 IDB 事务外计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。 */
/** 在 IDB 事务外逐项计算原图 SHA256 并验长度,避免 await crypto 让事务提前失活。
* 借用已捕获 cut;未附 bytes 的项不重读存储。一次整 Blob 转 ArrayBuffer,没有分块预算/取消信号。
* 摘要证明内容匹配声明,不验证服务权限、实际图片解码尺寸或 MIME;这些由其它端口负责。 */
export async function verifyDepotBytes(cut:DepotCut){
for(const row of cut.writes)if(row.bytes){if(row.bytes.size!==row.ref.asset.bytes)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);const digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await row.bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');if(digest!==row.ref.digest)throw fault('shared_asset_bytes_changed',[row.ref.asset.id]);}
}
/** Must run in the same transaction as the project Root, including idempotent reindexing. */
/** 必须与项目 Root 同事务:校验既存身份、按 digest 写共享字节,并更新该项目 pin manifest。 */
/** 必须与项目 Root 同事务:校验既存身份、按 digest 写共享字节,并替换该项目 pin manifest。
* 调用前须 detach/verify;本函数不重算摘要,也不自行核对 rootRevision/leader。完成只表示请求已排入事务。 */
export async function writeDepotCut(tx:IDBTransaction,scope:StorageScope,rootRevision:number,cut:DepotCut){
const base=prefix(scope,cut.authority),facts=tx.objectStore('asset-depot'),bytes=tx.objectStore('asset-bytes');
for(const row of cut.writes){const key=[...base,row.ref.asset.id],previous=await value<Fact|undefined>(facts.get(key));if(previous){validateFact(previous,cut.authority,row.ref.asset.id);if(!equal(previous.ref,row.ref))throw fault('shared_asset_identity_conflict',[row.ref.asset.id]);}
// Digest-addressed bytes are shared across resource IDs and projects in this authority.
// 同 account/workspace/authority 的不同资源 ID 共用 digest 字节行。
// 再提供同一身份的已验证 bytes 可清掉软标记与 purgedAt;只有元信息的写入保持既有退休状态。
if(row.bytes)bytes.put(row.bytes,[...base,row.ref.digest]);
facts.put({...previous,ref:row.ref,...(row.bytes?{unreferencedAt:undefined,purgedAt:undefined}:{})},key);
}
@@ -57,9 +66,10 @@ function depotFile(row:unknown,id:string):DepotFile{
if(file.kind!=='asset-depot'||file.resourceId!==id||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||!Number.isSafeInteger(file.byteLength)||file.byteLength<1||file.byteLength>20*1024*1024||typeof file.mimeType!=='string')throw fault('corrupt_file_store');
return file;
}
/** Undefined means bytes were explicitly evicted; identity metadata remains. */
/** Blob 旧行直接返回;指针行核对共享身份/长度,字节已显式清理则返回 undefined。
* 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。 */
/** Blob 旧行与不存在的行直接返回;指针行核对共享身份/长度,已 purge 或共享字节缺失均返回 undefined。
* undefined 本身不能区分从未写入、显式回收和缺失字节;缺少身份事实或字段冲突则抛损坏错误。
* 返回时恢复项目原文件 MIME,不因服务检测 MIME 与本地不同而改变原文件合同。
* 不重新哈希、不向服务器授权/下载;调用方须维持事务生命周期及外层读权限。 */
export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:string,row:unknown):Promise<Blob|undefined>{
if(row===undefined||row instanceof Blob)return row;
const file=depotFile(row,id),base=prefix(scope,file.authority),fact=await value<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id]));
@@ -73,7 +83,10 @@ export async function resolveDepotFile(tx:IDBTransaction,scope:StorageScope,id:s
return bytes.type===file.mimeType?bytes:bytes.slice(0,bytes.size,file.mimeType);
}
/** Convert only Root-proven stored uploads, after shared bytes and local files are written. */
/** Root 已证明 stored upload 且共享字节存在时,把项目 Blob 行改为指针;未确认上传绝不提前转。 */
/** 从已校验 Root 中读取 stored upload 声明,把本次 cut.writes 对应的项目 Blob 行改为共享指针。
* 只检查声明/身份与 Blob 长度和本地 MIME,不重新对比两份文件内容;字节摘要证明依赖外层流程。
* 缺 resource.stored、资源已 purge、无项目文件或无共享字节时跳过;不是扫描迁移项目中所有文件。
* 借用 Root/共享写入相同事务,后续任何冲突应由外层 abort,不能只保存部分已转换行。 */
export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key:string,image:RecoveryImage,cut:DepotCut){
type Resource={id:string;digest?:string;byteLength:number;mimeType:string;stored?:SharedAsset['asset'];retirement?:{purgedAt?:number}};
const payload=image.payload as {runtime?:{resources?:readonly (readonly [string,Resource])[]}},entries=payload?.runtime?.resources;
@@ -92,15 +105,19 @@ export async function promoteDepotFiles(tx:IDBTransaction,scope:StorageScope,key
}
/** 注入主 adapter 的事务执行器,统一复用 metadata/fence/事务终态屏障。 */
type Execute=<T>(leader:StorageLeader,stores:string[],mode:IDBTransactionMode,run:(tx:IDBTransaction)=>Promise<T>)=>Promise<T>;
/** 为主 storage 装配 read/collect;调用者不能绕开有效 leader 直接操作跨项目共享行。 */
/** 为主 storage 装配 read/collect;leader/fence/事务终态由注入的 execute 保证,而非本工厂自行实现。
* 工厂不建立第二份缓存或连接;这些数据库身份检查也不替代远端资源授权。 */
export function createDepotStorage(execute:Execute):AssetDepotPort{return Object.freeze({
/** 读取本域身份与尚未 purge 的字节,元信息及 Blob 类型/长度都校验,缺字节不丢身份。 */
async read(leader,scopeAuthority,id){authority(scopeAuthority);if(typeof id!=='string'||!id)throw fault('invalid_resource_id');return execute(leader,depotStores,'readonly',async tx=>{const base=prefix(leader.scope,scopeAuthority),row=await value<Fact|undefined>(tx.objectStore('asset-depot').get([...base,id]));if(!row)return;validateFact(row,scopeAuthority,id);const bytes=row.purgedAt===undefined?await value<Blob|undefined>(tx.objectStore('asset-bytes').get([...base,row.ref.digest])):undefined;if(bytes!==undefined&&(!(bytes instanceof Blob)||bytes.size!==row.ref.asset.bytes))throw fault('corrupt_asset_depot');return freeze({...row,...(bytes?{bytes}:{})}) as DepotEntry;});},
/** 一次至多128身份的两阶段回收:先软标记,保留期过后 purge;完整性门禁覆盖所有本地项目。 */
/** 一页至多128身份的两阶段回收:先软标记,之后另一次访问满足保留期才 purge(即使 retentionMs=0)。
* 每次仍 getAll 本地项目 metadata 与本授权域全部 fact,页大小不限制全量扫描/驻留量。
* complete=true 只表示清单门禁通过;若有 nextAfter,调用者还需继续下一页,不等于整仓库已回收。 */
async collect(leader,input){authority(input.authority);const {now,retentionMs,limit,after}=input;if(!Number.isSafeInteger(now)||now<0||!Number.isSafeInteger(retentionMs)||retentionMs<0||!Number.isSafeInteger(limit)||limit<1||limit>128||after!==undefined&&(typeof after!=='string'||!after))throw fault('invalid_depot_collection');
return execute(leader,depotStores,'readwrite',async tx=>{
const scope=leader.scope,metadata=tx.objectStore('metadata'),keys=await value(metadata.getAllKeys()),heads=await value<{throughRevision?:number}[]>(metadata.getAll()),base=prefix(scope,input.authority),manifests=tx.objectStore('asset-pins'),blockedProjects:string[]=[],pinned=new Set<string>();
// 包括已关闭项目:其 manifest 必须与当前 Root 头一致,任一缺失/过期/opaque 都阻止删除。
// getAllKeys/getAll 在同一事务按相同键序对应。包括同账号工作区的已关闭项目;尚无可靠 Root 的头跳过。
// 每个有 Root 项目的本授权域 manifest 必须同 revision 且 complete;不要求项目当前持有 leader。
for(let i=0;i<keys.length;i++){let parts:unknown;try{parts=JSON.parse(String(keys[i]));}catch{throw fault('corrupt_storage_scope');}if(!Array.isArray(parts)||parts.length!==3||parts.some(p=>typeof p!=='string'||!p))throw fault('corrupt_storage_scope');if(parts[0]!==scope.accountId||parts[1]!==scope.workspaceId||heads[i].throughRevision===undefined)continue;
const pins=await value<Pins|undefined>(manifests.get([...base,parts[2]]));if(!pins||pins.version!==1||pins.rootRevision!==heads[i].throughRevision||pins.complete!==true){blockedProjects.push(parts[2]);continue;}
if(!Array.isArray(pins.pins))throw fault('corrupt_depot_pins');for(const row of pins.pins){if(!Array.isArray(row)||row.length!==2||typeof row[0]!=='string'||!row[0]||!Array.isArray(row[1])||!row[1].length||row[1].some(owner=>typeof owner!=='string'||!owner))throw fault('corrupt_depot_pins');pinned.add(row[0]);}
@@ -109,11 +126,14 @@ export function createDepotStorage(execute:Execute):AssetDepotPort{return Object
if(blockedProjects.length)return freeze({complete:false,blockedProjects,markedIds:[],purgedIds:[],pinnedIds:[]});
const store=tx.objectStore('asset-depot'),factKeys=await value(store.getAllKeys(range(scope,input.authority))),rows=await value<Fact[]>(store.getAll(range(scope,input.authority))),markedIds:string[]=[],purgedIds:string[]=[],pinnedIds:string[]=[];
for(let i=0;i<rows.length;i++){const key=factKeys[i];if(!Array.isArray(key)||key.length!==4||typeof key[3]!=='string')throw fault('corrupt_asset_depot');validateFact(rows[i],input.authority,key[3]);}
// 先验证本域记录,再按资源 ID 游标截取一页,多取一条判断是否还有下一页。
// 所有 fact 先验证再分页:页外损坏也阻止本次回收。复合 IDB 键序提供资源 ID 顺序,after 严格排除自身。
// 多取一条只判断后续候选;已 purge/仍 pinned 的身份也占页名额,不是 limit 个实际删除。
const candidates=rows.map((row,i)=>({row,key:factKeys[i] as string[]})).filter(({key})=>after===undefined||key[3]>after).slice(0,limit+1),page=candidates.slice(0,limit);
// repin 清软标记;无引用首次记录时刻;时钟倒退不提前 purge;永久身份不重新分配。
// repin 只清尚未 purge 的软标记;已 purge 身份即使重新被 pin 也不在此复活字节。
// 未引用首次只记录时刻;之后满足保留期才 purge,时钟倒退不提前清理;身份行始终保留。
for(const {row,key}of page){const id=key[3];if(pinned.has(id)){pinnedIds.push(id);if(row.purgedAt===undefined&&row.unreferencedAt!==undefined){delete row.unreferencedAt;store.put(row,key);}continue;}if(row.purgedAt!==undefined)continue;if(row.unreferencedAt===undefined){row.unreferencedAt=now;markedIds.push(id);store.put(row,key);}else if(now>=row.unreferencedAt&&now-row.unreferencedAt>=retentionMs){row.purgedAt=now;purgedIds.push(id);store.put(row,key);}}
// Preserve a digest while any other live resource identity still owns its bytes.
// rows 与 page 共用本事务读出的 row 对象,已写的 purgedAt 可直接参与全域存活判断。
// 同摘要任一身份尚未 purge(即使未 pin、仍在保留期)都保留字节;最后一份退休后才删 digest 行。
for(const digest of new Set(page.filter(({row})=>row.purgedAt===now).map(({row})=>row.ref.digest)))if(!rows.some(row=>row.ref.digest===digest&&row.purgedAt===undefined))tx.objectStore('asset-bytes').delete([...base,digest]);
return freeze({complete:true,blockedProjects:[],markedIds,purgedIds,pinnedIds,...(candidates.length>limit?{nextAfter:page.at(-1)!.key[3]}:{})});
});
+20 -9
View File
@@ -27,32 +27,43 @@ export type AssetDepotPort={
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>;
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
};
/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */
/** 校验版本、非空授权域/URL、64 字符小写摘要、图片 MIME、正整数尺寸/字节及像素/20MiB上限。
* 只检查元数据:URL 未限制协议/域,摘要未重算,尺寸未通过实际解码核实;授权真实性由服务端口保证。 */
export function validateSharedAsset(value:SharedAsset):void{
if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset');
const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset');
}
/** 验证后捕获并冻结资源元信息,隔离调用者可变的 asset 对象。 */
/** 验证后重建标准外层字段并浅复制 asset,再递归冻结,隔离标准标量元信息。
* asset 的扩展属性仍由 spread 保留,嵌套扩展值不深克隆,不能当作任意外部对象的完整隔离入口。 */
export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});}
/** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */
const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b));
/** Immutable metadata cache only. Every new adoption resolves current permission first. */
/** 建立工作区门面,绑定作用域与当前身份,不把跨账号缓存混入同一个 records 表。 */
/** 捕获 account/workspace 标量,建立仅存元信息的 Map;字节仍由 port 持有,不建立内存 Blob 缓存。
* 宿主 current/leader 校验约束使用生命周期,实际存储 fence/权限检查仍由端口实现。
* 每次 getResources 返回成员快照,旧快照不随 remember/close 改变;它不另做授权或关闭检查。 */
export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
/** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */
/** 入口及异步结果采纳前检查宿主/leader,允许同账号工作区的不同项目共享。
* 不比较 projectId,不读取远端 fence;在途 I/O 是否立即停止由端口/信号决定。 */
const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');};
/** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */
function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));}
return Object.freeze({scope,getResources:()=>readMap(records),
/** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。
* 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */
/** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。
* combined 信号交端口,异步返回后再检查;入口不先 throwIfAborted,已取消信号仍可能传给 resolve。
* remember 捕获元信息,但返回 ref 沿用 resolve 结果(只冻结返回 envelope),端口须提供不可变身份。
* 成功不写共享字节/项目 binding;这些由 engine 同 Root 持久化,不是新上传任务。 */
async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}},
/** 读取已绑定本地共享字节并验身份/摘要;不是新 adoption 的授权捷径,缺字节返回 undefined。 */
/** 按已绑定 ref 读取本地共享字节并验身份/摘要,不调用 authority.resolve 或回源下载。
* 无记录直接 undefined;有记录无字节也记下元信息再返回 undefined,不能据此判定服务器资产消失。
* 输入 ref 沿用调用方身份;本方法不单独证明调用者有权新采纳该资源。 */
async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;},
/** 作用域检查后委托存储扫描/保留期回收,结束再检查身份,不自行删除服务器资产。 */
/** 作用域检查后直接委托 port.collect,结果由端口负责结构与回收证明,不在此重验。
* 返回后再核对身份;检查失败不代表端口之前的本地存储事务未执行,也不删除服务器资产。 */
async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;},
/** 关闭门面并 abort 授权任务、清元缓存;不抹掉共享存储中的持久身份/字节。 */
/** 关闭后拒绝新操作,abort 本门面追踪的 authorize 并清元缓存;不删除持久身份/字节。
* read/collect 没有被加入 controllers,在途结果由后续 check 拒绝采纳;close 不等待底层 I/O 退出。 */
close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();},
});
}
+13 -5
View File
@@ -10,7 +10,8 @@ import { compressChanges, type LedgerSnapshot } from './ledger.ts';
/** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */
const encoder = new TextEncoder();
/** 仅计算候选 JSON 表示的字节成本,不在这里发送或冻结请求。 */
/** 仅比较传入 JSON 的 UTF-8 字节;调用处测量 operation 数组,不含 HTTP 头/请求 envelope。
* 不在这里发送或冻结请求,也不估算压缩传输后的成本。 */
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
/** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */
@@ -19,9 +20,13 @@ export type WireOperation = { readonly id: Id; readonly changes: readonly Change
const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id)));
/** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */
/** 尝试无损压缩多意图的 wire 投影;不足两项或任一不安全前提直接返回 ordered。 */
/** 为调用方已选好的未发送段尝试 wire 压缩;不足两项、基线不明或无收益时返回 ordered。
* 原始条件链非法会抛错,投影效果不等价也会抛错,并非所有失败都回退。
* 此处只比对 ledger 的 changes/phase 等局部条件,不验证完整意图身份、依赖、类型/版本或段连续性。
* 参与范围与选择合法性由调用方保证;这里不调用 registry/整图校验来证明所有权或权限。 */
export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] {
const scope = new Set(participants);
// 回退表示创建新的外层数组/条目,但 changes 沿用输入引用;仅成功压缩的结果走末尾 freeze。
const ordered = intents.map(({ id, changes }) => ({ id, changes }));
if (intents.length < 2) return ordered;
// 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。
@@ -52,9 +57,11 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
// wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。
const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true });
if (!compacted.length) return ordered;
// 原逻辑 ID 顺序逐项保留;函数本身不验证输入 ID 唯一性,去重身份由 ledger/请求编译边界保证。
// Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit
// conditional no-op; the final ID carries the composed effect. All IDs remain deduplicated.
// 从参与 owner 的少量元素 title/text 或根 canvas 选 no-op guard,不扩大原 claim 范围。
// conditional no-op; the final ID carries the composed effect. Input ID order is retained.
// 从参与 owner 的基线元素选 title/text 条件 no-op,最多查看 8 个候选元素,每个产生两个 guard。
// guard 表达的是基线值/存在性,不是某个前缀逻辑操作的独立最终效果;最后一项统一携带组合变化。
const guards: Change[] = [];
let candidates = 0;
for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) {
@@ -73,7 +80,8 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
if (!guard) return ordered;
const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] }));
if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered;
// 最后执行投影并对比 elements/canvases/tombstones 全效果,不只比较可见节点内容。
// 在同一 sparse 片段执行投影,比较它的三张表而非完整项目;未触及记录从未纳入片段。
// 这证明该基线上的组合效果一致,不替代请求执行时的版本检查或最终文档校验。
const result = applyChanges(sparse, projected.flatMap(operation => operation.changes));
if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch');
return freeze(projected);
+6 -1
View File
@@ -5,7 +5,10 @@
import {fault} from '../canvas-core/values.ts';
import {createDigestPool} from './digest-pool.ts';
/** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */
/** 构建 workspace 可共享的 DigestPort;任务 ID/长度/版本共同验证响应归属。 */
/** 在构造时选择能力:Worker 不存在就使用池的默认分块执行器,否则每个 execute 新建专用 Worker。
* 此选择不随后续 Worker 全局变化自动重算;创建/运行失败也不降级重试。
* 使用默认池预算,响应按实例内递增 ID/输入长度/版本和摘要格式核对,不独立重算摘要。
* 没有超时,线程无响应需外部取消/close;Worker 内原生哈希阈值由 digestWorkerSource 控制。 */
export function createBrowserDigests(){
let next=0;
return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{
@@ -15,6 +18,8 @@ export function createBrowserDigests(){
const id=++next;let done=false;
/** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */
const finish=(error?:unknown,sha256?:string)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(sha256!);},abort=()=>finish(fault('import_aborted'));
// 稳定 digest_worker_failed 只有身份匹配才采纳;其它响应走摘要字段校验,不把任意 error 字段当服务错误。
// 格式正确只表明消息可解释,真实性来自受信的专用 Worker 算法;不在 UI 线程重复计算。
// 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。
signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response'));
// Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。
+4 -1
View File
@@ -4,7 +4,9 @@
*/
import {fault} from '../canvas-core/values.ts';
import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts';
/** 返回 ImageDecoderPort;版本/id/字节数验证响应归属,尺寸合法性由 pool 再检查。 */
/** 将每次 execute 转成专用 module Worker,请求 ID 在此 decoder 实例内递增。
* 外围池使用默认并发/排队预算;此适配按响应版本/id/输入长度核对归属,尺寸合法性由池检查。
* 没有超时;Worker 无响应时要由外部取消/close 才能终止,构造失败也不回退主线程解码。 */
export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise<ImageDimensions>((resolve,reject)=>{
if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;}
// 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。
@@ -12,6 +14,7 @@ export function createBrowserImageDecoder(){let next=0;return createImageDecodeP
/** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */
const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted'));
signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;}
// Worker error 会 preventDefault 并转稳定错误,消息无法反序列化则拒绝;所有路径共用 finish 终止线程。
// 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。
worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response'));
// 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。
+26 -10
View File
@@ -13,7 +13,10 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
/** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> };
/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */
/** 从当前 working 反向遍历意图,重建每项的局部前后上下文;可变 cursor 永不作为业务文档发布。 */
/** 从已包含这些意图效果的 working 逆序退回,重建局部前后记录及 owner 变换。
* 输入顺序就是应用顺序;函数不按 sequence 排序,也不验证当前文档确由这些意图产生。
* 这里只还原意图元数据,不按 before/after 条件执行撤销,不回退墓碑、不分配新代次或发布文档。
* 只读 Map 捕获表成员,记录值沿用不可变输入;不是任意外部对象的深克隆边界。 */
export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> {
const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
const cursor={...document,elements,canvases};
@@ -27,7 +30,8 @@ export function intentContexts(document:Document,intents:readonly OperationInten
}
const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}}
const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner));
// 私有 cursor 按原 before 逆向退回,不执行用户可见的 restore,也不因此分配新 incarnation。
// 直接把记录/字段写回原 before,不检查当前是否等于 after;field 缺对象/代次不符时跳过。
// cursor 中间树可不完整,随后仅在 owner 仍存在时取变换;无法解析的祖先链仍可能抛错。
for(const change of [...intent.changes].reverse()){
if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);}
else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);}
@@ -51,7 +55,9 @@ function sameConvertedPath(left:Json,right:Json):boolean {
const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);};
return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);});
}
/** 只把原意图实际改过的 presentation 键应用到 current;unitScale 另走视觉单位换算。 */
/** 以键的存在性和值比较找 presentation 顶层差异,再覆盖/删除 current 的对应键。
* 嵌套键值作为整体处理,不递归合并;unitScale 排除在本函数外,交几何分支做视觉单位换算。
* 无变化时沿用 current;有变化后若结果无键则返回 undefined,交调用方处理字段存在性。 */
function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined {
if(equal(before,after))return current;
const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale');
@@ -63,6 +69,7 @@ function presentationDelta(current:Json|undefined,before:Json|undefined,after:Js
* 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */
function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){
const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world);
// 世界中心用于分别判断横/纵移动意图;未变化的维度取 fresh,修改维度取原意图终值,而非叠加 delta。
const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height;
const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation;
const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination);
@@ -75,7 +82,9 @@ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBef
}
/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */
/** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。 */
/** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。
* 仅段内依赖参与 Kahn 计数;段外依赖是否存在留给执行/最终校验。元素与 canvas 分命名空间。
* ready 按发现顺序排队,不保证每次都选择剩余项中原索引最小者;重 ID 或环直接拒绝。 */
function orderedCreates(changes:readonly Change[]):readonly Change[]{
const result:Change[]=[];
/** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */
@@ -101,17 +110,22 @@ function orderedCreates(changes:readonly Change[]):readonly Change[]{
return result;
}
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */
/** 在 fresh 私有文档上重新生成条件 changes,原 intent/context 保持不变;最终完整校验才返回。 */
/** Re-plan against current data; whole element replacement is unsupported. */
/** 在 fresh 私有候选上重新生成条件 changes,保留当前未被用户意图修改的数据,最终整图校验才返回。
* 本函数不验证恢复选择/账本依赖,不发布 Root、不记录 history、不修改原冻结请求;这些由冲突协调器负责。
* 整元素仅支持显式增删,canvas 记录可重建 parent;普通字段的 before 从 fresh 实值重新生成。
* 输入应为已捕获的不可变模型/上下文,辅助 apply 的冻结不构成任意外部对象的深隔离保证。 */
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
// moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[];
for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id);
/** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
/** 同 ID 还必须同 incarnation/结构/组件/版本且 registry 可解释,防止旧意图套到新对象上。 */
/** 目标必须存在、代次相同且 registry 已知;上下文含 prior 时再比较结构/组件/版本。
* 这是字段分支共用准入,具体能力检查在各分支;上下文缺 prior 不在这里补造类型依据。 */
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
/** 端点必须仍为原上下文中的同代次节点;不能把恢复出来的新节点视为旧外部引用。 */
/** 当前端点须为 node,且与 context.after 中对象代次一致;无上下文也拒绝。
* 这里不单独比较端点的 componentId/dataVersion,图关系是否合法仍由最终校验判断。 */
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
// 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。
for(const original of orderedCreates(intent.changes)){
@@ -132,7 +146,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
}
apply({...original,after});
// 已经不存在的删除可满足;仍存在时按 fresh 拥有树规划移除,不能直接覆盖旧整记录。
// 删除目标已不存在时直接满足,不在此以墓碑证明它曾是哪一代;仍存在则要求旧代次和已知类型。
// 按 fresh 拥有树重新规划删除,可包含远端新增子内容/关联边,而不是照抄旧删除清单。
}else if(original.before&&original.after===null){
if(!current)continue;
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]);
@@ -210,7 +225,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
if(!equal(edge.path,path))apply({...original,before:edge.path,after:path});
}
// 所有端点/树变化结束后统一修复边 owner/path,再验证最终文档,避免暴露半份图。
// 使用初始 fresh 与最终候选修复受影响边的 owner/path,再做整图校验;空 changes 也要校验。
// 抛错不返回半成品,成功仍只是规划结果;是否采纳以及新的 operation/history 身份由调用方决定。
for(const change of repairConnectionOwners(before,document))apply(change);
validateDocument(document,registry);return{document,changes:freeze(changes)};
}
+25 -11
View File
@@ -26,7 +26,8 @@ export type RecoveryChoice = 'keep-local' | 'discard-local';
export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' };
/** 恢复审计记录:绑定规划时的根 revision,保留备份及可用的历史重放证据。 */
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition; readonly historyReplay?:HistoryReplay };
/** 待一次性发布的候选根及引用资源;baseRootRevision 供提交方拒绝过时方案。 */
/** 待采纳的文档/历史/操作/账本候选及审计,非完整 EngineSnapshot;引擎还需结合 canonical 版本等事实发布。
* baseRootRevision 用于拒绝过时方案,resourceIds 是本次已知保留集合,operationIds 只列新替代操作。 */
export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] };
/** 字段身份包括元素代次,避免把删除后重建的同 ID 元素算成同一字段历史。 */
const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]);
@@ -41,8 +42,11 @@ function value(document: Document, change: FieldChange, allowMissing=false): {be
/** 把文档 Map 转成备份用的 JSON 形状;序列化的是数据,不是运行时方法或索引。 */
const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]});
/** 纯规划入口:先校验权威读取与回执已全部落定,再按操作顺序投影用户决策。
* 不复用旧请求键/请求体;未知结果尚未确认时拒绝规划,防止重复执行已经生效的修改。 */
/** 在不可变 snapshot 上规划恢复:先验 canonical 单调性/完整性,并要求 ledger 中请求 phase 全为 resolved。
* 不自己查询回执、认证来源或等待磁盘;canonical/账本已捕获且可信由 engine/端口负责。
* 取全部非 resolved 且非 asset.upload 意图,要求 choices 精确覆盖每项;依次重规划、重建 history、退休旧项并加入替代项。
* 不发布/持久化/提交请求;createId 可消耗外部编号,失败不会回滚该回调副作用。
* 小输入/模型按既有不可变合同使用,备份 JSON 编码与全部恢复规划同步完成,没有时间片/容量预算。 */
export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan {
if(snapshot.runtime.loading)validateLoadingCompletion(snapshot.runtime.acknowledged,snapshot.runtime.versions,snapshot.runtime.loading,canonical.document,canonical.versions,registry,true);else validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry);
if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts');
@@ -59,7 +63,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
// 边界记录历史单元恢复前后的文档;starts 保存首次触及字段时的基线,供混合 ACK 历史核对。
const boundaries=new Map<Id,{before:Document;after:Document}>(),starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent));
for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);}
// 有历史转移证据或之前已采用重放的操作时,优先用日志恢复保持 undo/redo 的语义。
// 全部 content 有原生 transition、某个遍历有 transition,或命中已有 adoption 时,进入严格日志恢复。
// 这只是分支选择,不证明证据齐全/可回放;journal 构造和后续 project/accept 仍可拒绝不完整/矛盾记录。
const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])));
const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id));
const journal=hasEvidence?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined;
@@ -78,7 +83,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
}
// 用户新建 canvas 的身份若已被服务端占用,不能悄悄视为同一对象。
if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
// keep-local 在新基线上重新计算改动;discard-local 不施加该意图,日志仍记录该决定。
// 每项边界 before→after 反映此前全部审阅结果;keep 在该新候选上规划,discard 不施加原文档变化。
// journal.project 还可能先推进已有结算历史;没有 journal 时直接走 rebaseIntent,不能并行按同一旧基线处理。
const rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
const changes=rebased.changes;document=rebased.document;
if(entryId)boundaries.get(entryId)!.after=document;
@@ -87,7 +93,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
// 生成启动、取消等动作需重新发起命令;冲突审阅不能顺手重新执行可能付费的任务。
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
if(changes.length){
// 依赖替换为对应的新操作,丢弃或已满足的依赖不再排队;再串联此前所有替代操作。
// 原依赖若已审阅:改指对应 replacement,或在丢弃/已满足时移除;未审阅的既有依赖保留原 ID。
// 再依赖所有此前新 replacement,保证后项看到前项的串行效果;新 sequence 从账本最大值后递增。
const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]);
// New dependencies reference new logical facts; a discarded parent never silently starts a paid run.
const dependencySet=new Set(dependencyIds);
@@ -102,6 +109,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
}
journal?.accept(intent,replacement,choice==='keep-local',before,document);
replacements.set(intent.id,replacement);
// satisfied 只表示这次规划无需新 changes;不是在此收到另一份服务器 applied 回执。
decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})}));
}
// 根据现有证据选日志重放、撤销/重做重规划或兼容历史重写,拒绝无法证明安全的路径。
@@ -113,20 +121,24 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
ledger.add(replacement,presence);
}
const historyReplay=journal?.evidence();
// Conservative pins include the archived document/history and unresolved opaque local resources.
// 已知资源保留集合:先纳入本地 resources 全部 ID,再补原/替代 changes 与历史 replay 中可解释引用。
// 不在此推断 opaque payload 的所有私有引用,也不把资源 ID 清单当作已保存 Blob 字节的证明。
const resourceIds=new Set(snapshot.runtime.resources.keys());
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
if(historyReplay)for(const transition of [historyReplay.anchor,...historyReplay.events.map(event=>event.transition)])for(const resource of referencedResources(historyTransitionChanges(transition),registry))resourceIds.add(resource);
// 同时备份旧 working、ACK 基线和历史;资源 pin 保护恢复证据,防止审阅后被清理。
// backup 捕获旧 working/ACK/版本/history 和资源 ID;不嵌入 Blob、服务回执或完整 workspace 快照。
// 顶层 audit transition 描述旧→新历史,journal evidence 另解释内部事件;两者都须与恢复 Root 同次保存。
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1,undefined,!!historyReplay);
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{}),...(historyReplay?{historyReplay}:{})});
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
}
/** 无完整日志证据时的兼容历史重写。仅支持能够证明前缀/字段基线的撤销栈项,
* 不推测交错 ACK、结构变更或不支持的历史遍历;无法验证时抛错让整个规划保持未发布。 */
/** 无 journal 的兼容分支,仅重写受本批 pending 影响的 undo 项,且其关联操作都必须是已知 user origin。
* 全项未决时直接用 replacements 拼回;混合已确认贡献时,结构项要求确认贡献为完整前缀,纯字段按身份键分别审阅。
* 不生成完整事件 replay,不重规划 redo 或任意交错遍历;不满足证据条件即拒绝本次方案,交外层保持未发布。
* 返回私有 entry 表/undo 候选,未影响 entry 与 redo 数组沿用不可变快照引用。 */
function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id));
for(const entry of snapshot.history.entries.values()){
@@ -155,7 +167,8 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
continue;
}
// 纯字段混合历史按“目标代次 + 字段”分组;确认部分是否保留取决于恢复起点的值和存在性。
// 纯字段按 ID/代次/field 分组,先验证该字段历史尾部确为 oldPending,再分离已确认 prefix。
// starts 记录本次审阅首次触及该字段的 fresh 值/存在性;只在它仍等于旧 pending 的 before 时保留确认前缀。
const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
const fields:FieldChange[]=[];
for(const [key,example]of keys){
@@ -169,6 +182,7 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix);
fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key));
}
// 未触及字段保留;重建字段链追加到后面。此分支已排除 owner/端点等结构变化,不用该方式重排结构依赖。
const changes:Change[]=[...entry.changes.filter(change=>change.kind!=='field'||!keys.has(fieldKey(change))),...fields];
const operationIds=entry.operationIds.flatMap(operationId=>affected.has(operationId)?replacements.get(operationId)?[replacements.get(operationId)!.id]:[]:[operationId]);
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
+19 -7
View File
@@ -12,7 +12,9 @@ import type { RunRecord } from './runs.ts';
import type { ResourceRecord } from './resources.ts';
import type { RecoveryAudit } from './conflicts.ts';
/** 持久运行事实集合;view/selection/gesture 和渲染缓存不放入该结构。 */
/** 与 core 文档/历史同 Root 发布的运行事实;view/selection/gesture 和渲染缓存不在此持久状态中。
* 这是类型合同,不执行输入校验/序列化或深冻结;Map/记录的实际捕获由 engine、codec 和生产模块负责。
* 内存 Root 的变化不等于磁盘 durable 切点,调度仍需存储确认与 durableGeneration/rootRevision 等屏障。 */
export type RuntimeState = {
/** 项目资源与共享 AssetDepot 的授权/身份绑定,非第二份图片字节。 */
readonly assetBindings?:ReadonlyMap<Id,import('./asset-depot.ts').SharedAssetBinding>;
@@ -46,17 +48,25 @@ export type RuntimeState = {
};
/** core Snapshot 的 runtime 具象化,单次订阅可同时看到模型和对应运行事实。 */
export type EngineSnapshot = Snapshot<RuntimeState>;
/** 经过服务 codec 校验的文档与版本向量候选;只有协调完成后才能成为新基线。 */
/** 服务 codec 提供的文档/版本候选,按合同应已校验;类型没有品牌或运行时校验,任意外部对象仍需边界验证。
* 不包含 history/ledger/Root revision,也不自动证明完整捕获或当前授权;协调完成后才可作为 ACK 基线。 */
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
/** Receiving records are read deltas, not a validated Document or an ACK base. */
/** receiving 是尚未完成的读取增量,数量/owner 证据不表示整份 payload 已通过校验。 */
/** receiving 为尚未完成的 delta:头部一次声明 canvas/墓碑/可选范围证据,后续按读取协议增加元素。
* received 可包括依赖未闭合、尚不能发布的元素;不是完整 Document、ACK 或可直接提交给 editor 的 working。
* CanonicalReadUpdate 不携带网络 query/token/sequence 的完整围栏,那些由外层 LoadChunk 绑定。 */
export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly canvasElementCounts?:import('./scope-counts.ts').CanvasElementCounts;readonly elementOwners?:import('./scope-counts.ts').ElementOwners; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap<Id,number>; readonly versions:ReadonlyMap<Id,number>; readonly receivedElements:number; readonly totalElements:number };
/** ready 提供新模型;migration-required 描述显式迁移资格,不在读取时偷偷执行迁移。 */
export type CanonicalReadResult = ({ readonly status: 'ready';readonly sourceToken?:string } & CanonicalProposal) | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap<Id, number> };
/** A validated historical execution receipt; its old model is never published over a fresh read. */
/** 验证历史 applied 回执的执行证据;assertCanonical 比对 fresh read,不发布旧回执里的旧模型。 */
/** 由请求解释器验证历史 applied 回执后构造:versions 表示该次写入下界,assertCanonical 检查 fresh 内容兼容。
* proof 不包含待发布旧模型,也不持久化函数;恢复时由原请求/回执重新解释,回调应纯且基于不可变证据。
* 证明服务执行不等于本地已协调/已保存,更不授权再提交相同变化。 */
export type AppliedModelProof = { readonly versions:ReadonlyMap<Id,number>; assertCanonical(proposal:CanonicalProposal):void };
/** 增量服务端口:compile/canonical 是纯编解码,submit/query/read 是可取消外部 I/O。 */
/** 服务适配合同:compile/canonical/verifyApplied 等同步方法负责纯编解码/验证,异步方法负责外部 I/O。
* 引擎拥有本地发布/重放/持久化顺序;adapter 不应直接修改 snapshot、history 或 ledger。
* AbortSignal 只表达取消要求,不证明远端操作没生效;任何失败/超时都须按原 request 身份处理。
* 接口声明不会自动执行这些规则,具体实现和协调入口必须分别校验。 */
export type PersistencePort = {
/** Complete canonical read for a quiescent recovery session; never a legacy fallback. */
/** 恢复协调所需的完整 canonical read;不能拿 legacy fallback 当目标协议基线。 */
@@ -65,7 +75,8 @@ export type PersistencePort = {
verifyApplied?: (request:FrozenRequest,receipt:Json) => AppliedModelProof;
/** 判断是否有原冻结协议解释器,不支持时保留原 body/key 并阻塞,不能重编码换身份。 */
supportsRequest?: (request: FrozenRequest) => boolean;
/** 将稳定意图与当前基线编成待冻结请求;网络发送前仍需满足持久化屏障。 */
/** 从稳定 intent/requestId/当前快照计算参与范围、expected versions 与待冻结 payload,不执行网络写入。
* scheduler/ledger 随后冻结字节并过持久屏障;重试已经冻结的请求应复用原 body,不以最新快照重编。 */
compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot): CompiledRequest;
/** Compile an ordered mutable batch; logical IDs and original intents remain independent. */
/** 可把有序未冻结意图合并为一份 wire 请求,原 logical IDs 与意图事实仍独立保留。 */
@@ -78,6 +89,7 @@ export type PersistencePort = {
/** 可选精简回执 codec,仅在联合已协调发布时采用;失败应保留原恢复输入。 */
compactResolvedReceipt?: (request:FrozenRequest,receipt:Json,applied:CanonicalProposal)=>Json;
/** Pure codec, merging only participants into the latest base, never an old working snapshot. */
/** 将回执参与者合并进最新已确认基线,不能拿旧 working 快照替换新本地编辑。 */
/** 解释原请求/结果,对最新 acknowledged 合并参与者并返回提案,不能拿旧 working 覆盖本地新编辑。
* 不自行发布 ACK 或重放 pending;引擎还需校验候选与版本,重建 working 后统一发布。 */
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): CanonicalProposal;
};
+5 -2
View File
@@ -5,12 +5,15 @@
import {fault} from '../canvas-core/values.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
/** Share import scheduling while retaining the copy cancellation/error contract. */
/** 验证copy预算后包装ImportBudget;调用者只有真正await返回Promise才会交出执行权。 */
/** 复制任务的 ImportBudget 包装:构造与每次 step 都先检查取消,错误码使用 copy_aborted。
* 只预校验 frameBudgetMs;units/时钟/调度器沿用底层合同,单位可以是记录数或由调用方折算的字节工作量。
* 与同一个包装交互应顺序 await;没有独立计数器、持久进度或强制抢占能力。 */
export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){
if(signal.aborted)throw fault('copy_aborted');
if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget');
const budget=createImportBudget(signal,options);
/** signal已取消时将同步/异步预算失败统一为copy_aborted,其它错误原样抛给宿主。 */
/** 只在处理错误时 signal 已取消才改成 copy_aborted;其它错误保留原对象。
* 因此取消和宿主调度失败并发出现时,取消优先,不一定暴露原始调度错误。 */
const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;};
/** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */
return Object.freeze({step(units=1):Promise<void>|undefined{
+17 -6
View File
@@ -2,6 +2,7 @@
* 格式2的复制源:前段保存轻量topology和payload字节定位,后段保存原完整元素记录。
* 准备阶段只扫描索引/结构;真正复制某记录前才按range读取和校验payload,支持边转换边加入。
* 编码仍生成完整Blob,索引/定位仍占O(records)内存,不能据lazy解码宣称总内存常数。
* 打开索引不证明所有 payload 已读/哈希通过;字节完整性、权限和任务准入由外层负责。
*/
import type {CanvasRecord,Document,ElementRecord,Id,Json,Registry,Data} from '../canvas-core/types.ts';
import {createDocument,validateDocumentTopologySteps} from '../canvas-core/document.ts';
@@ -11,7 +12,8 @@ import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts';
/** Only ownership, endpoint and geometry fields needed to decide scope/coordinates. No component content. */
/** 保留ID/owner/端点/几何及contentScale/unitScale,清组件内容,边path置null用于范围/坐标规划。
* 返回结构记录不能直接作为用户元素发布,也不证明组件payload符合schema。 */
* 返回结构记录不能直接作为用户元素发布,也不证明组件payload符合schema。
* 本函数只创建外壳:geometry/端点和保留的 presentation 值仍借用原引用,没有校验或深捕获。 */
export function copyTopology(record:ElementRecord):ElementRecord{
const raw=record.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?Object.fromEntries(['contentScale','unitScale'].filter(key=>Object.hasOwn(raw,key)).map(key=>[key,raw[key]])):{};
const data:Data=Object.keys(presentation).length?{presentation}:{};
@@ -19,7 +21,9 @@ export function copyTopology(record:ElementRecord):ElementRecord{
return record.structure==='node'?{...common,structure:'node',geometry:record.geometry}:{...common,structure:'connection',source:record.source,target:record.target,path:null};
}
/** Format 2 places a compact topology/byte-locator index before the uninterpreted payload region. */
/** 输出完整format2源:header/canvas/element-index/tombstone/payload标记,然后原元素NDJSON区域。 */
/** 输出完整format2源:header/canvas/element-index/tombstone/payload标记,然后原元素NDJSON区域。
* 借用不可变 Document 合同,逐记录 await 后仍读取原表;没有在入口同步捕获整图或执行完整校验。
* index/payload 两个数组都留存到最终 Blob,预算只能在记录间让出,不能打断一次原生 stringify。 */
export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
const index:BlobPart[]=[],payload:BlobPart[]=[];const budget=createCopyBudget(signal,scheduling);let indexBytes=0,payloadBytes=0;
/** 单记录序列化、UTF8计长与256KiB上限,保持原始记录/索引locator成本可核验。 */
@@ -38,7 +42,9 @@ export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSi
/** document只含结构投影,readElement取得完整record;调用方必须明确使用topologyOnly规划模式。 */
export type IndexedCopySnapshot={readonly document:Document;readElement(id:Id,signal:AbortSignal):Promise<ElementRecord>};
/** Scan only the small index. Component JSON/schema checks occur just before each record enters the runner. */
/** 只扫描前段索引,验证locator连续/总长/项目与结构,再返回有界窗口读取器。 */
/** 只解析前段索引,验证locator连续/总长/项目与结构,再返回有界窗口读取器。
* NDJSON reader 可预读越过 payload 标记的原始字节,但打开时不会解析/验证每条完整元素内容。
* document 是独立捕获的结构文档,locators/窗口表留在闭包;对象冻结不替代 readElement 的后续校验。 */
export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<IndexedCopySnapshot>{
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'indexed-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<Id,number>(),locators=new Map<Id,{offset:number;byteLength:number}>();let cursor=0,header=false,payloadOffset=-1,expectedOffset=0;
@@ -62,17 +68,22 @@ export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:R
for(const _ of validateDocumentTopologySteps(document,registry))await budget.step();
if(signal.aborted)throw fault('copy_aborted');
// Cache raw byte windows, never decoded component objects. Preserve parser batching across Blob task boundaries.
// 缓存原始byte窗口而非解码对象,最多4窗口/1MiB,减少每记录Blob读取任务又限制常驻字节。
// 缓存原始 byte 窗口而非解码对象,表内最多4窗口/1MiB;命中不更新顺序,是插入/替换顺序淘汰。
// 上限只约束表内窗口,不包括 Blob、本次 raw、并发未完成的读取或解码结果;没有并发请求去重。
const windows=new Map<number,Uint8Array>(),windowBytes=64*1024;let cachedBytes=0;
/** readElement按locator截取原payload,fatal UTF8/JSON、topology一致及已知schema全部通过才返回冻结record。 */
/** readElement按locator截取原payload,fatal UTF8/JSON、topology一致及已知schema全部通过才返回冻结record。
* 每次命中仍重新解码/JSON.parse/校验,不缓存组件对象;此路径没有独立合作预算或异步 schema 执行。
* AbortSignal 只在入口及实际 Blob 读取后检查,不会打断同步 parse/validate;失败不清空已缓存字节。 */
return Object.freeze({document,async readElement(id:Id,signal:AbortSignal){
if(signal.aborted)throw fault('copy_aborted');const locator=locators.get(id);if(!locator)throw fault('element_missing',[id]);
// 从 payload 相对的64KiB边界起读,跨窗大记录扩大本次窗口;不靠 JS 字符下标定位多字节文本。
const start=Math.floor(locator.offset/windowBytes)*windowBytes,required=locator.offset+locator.byteLength-start;let window=windows.get(start);
if(!window||window.byteLength<required){const raw=await bytes.slice(payloadOffset+start,Math.min(bytes.size,payloadOffset+start+Math.max(windowBytes,required))).arrayBuffer();if(signal.aborted)throw fault('copy_aborted');const previous=windows.get(start);if(previous)cachedBytes-=previous.byteLength;window=new Uint8Array(raw);windows.delete(start);windows.set(start,window);cachedBytes+=window.byteLength;while(windows.size>4||cachedBytes>1024*1024){const[key,value]=windows.entries().next().value!;windows.delete(key);cachedBytes-=value.byteLength;}}
const raw=window.subarray(locator.offset-start,required);let value:{kind:string;record:ElementRecord};
try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(raw));}catch{throw fault('invalid_copy_snapshot');}
if(!value||value.kind!=='element'||!validJson(value.record)||!value.record.data||typeof value.record.data!=='object'||Array.isArray(value.record.data)||!equal(copyTopology(value.record),document.elements.get(id)))throw fault('invalid_copy_snapshot');
// 当前registry已知版本才执行其schema;后续复制转换还会要求对应copy解释器存在。
// 当前 registry 已知版本才执行其 schema;未知版本保留通用 JSON/拓扑,不等于已理解内容。
// readElement 不迁移版本、不执行复制解释器;这些要求由后续复制转换流程处理。
const record=value.record,definition=registry.components.get(record.componentId);if(registry.resolve(record)==='known'&&(!definition||definition.structure!==record.structure||!definition.validate(record.data)))throw fault('invalid_component_data',[id]);
return freeze(record);
}});
+12 -3
View File
@@ -1,6 +1,7 @@
/**
* 格式1的完整复制源快照与复制声明签名。源字节在任务准入前从一个不可变文档捕获,
* 后续规划/重开不读取当前用户正在改的live文档;源SHA与持久字节由外层完整性流程绑定。
* 编码器借用传入 Document 并在 await 后继续迭代,其不可变性是调用合同,并非函数进入时同步复制全图。
*/
import type { CanvasRecord, Document, ElementRecord, Registry } from '../canvas-core/types.ts';
import { createDocument, validateDocumentSteps } from '../canvas-core/document.ts';
@@ -10,7 +11,9 @@ import { createNdjsonImportSource } from './imports.ts';
import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts';
/** Capture immutable source bytes before a preparation job is admitted. No mutable live getters. */
/** 逐项编码header/canvas/element/tombstone为NDJSON,检查记录/总源上限并合作让出,最终返回完整Blob。 */
/** 逐项编码header/canvas/element/tombstone为NDJSON,检查记录/总源上限并合作让出,最终返回完整Blob。
* 每条 stringify/UTF8 计长仍同步完成,按含换行字节数折算预算;parts 留存全源,不是向持久层流式写入。
* 这里不验证 Document;借入的 getter/toJSON 如有副作用或抛错,也没有回滚机制。 */
export async function encodeCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
const budget=createCopyBudget(signal,scheduling),parts:BlobPart[]=[];let bytes=0;
/** 生成一条记录并累计实际UTF8字节,单条≤256KiB、总源≤64MiB;不省略未知组件payload。 */
@@ -19,13 +22,16 @@ export async function encodeCopySnapshot(document:Document,signal:AbortSignal,sc
for(const[kind,records]of [['canvas',document.canvases.values()],['element',document.elements.values()],['tombstone',document.tombstones.entries()]] as const)for(const record of records){const size=append({kind,record});await budget.step(Math.max(1,Math.ceil(size/1024)));}
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
}
/** 旧源完整解码,验证同project/header、墓碑唯一,重建并分片校验完整Document后才返回。 */
/** 旧源完整解码,验证同project/header、墓碑唯一,重建并分片校验完整Document后才返回。
* 记录读到 EOF 后才发布返回值,途中数组/Map 仅是私有暂存;不向 editor 逐项提交。
* 记录读取上限沿用 NDJSON reader,此入口没有 format2 那样的 Blob 总长检查;可信持久源大小由外层负责。 */
export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Document>{
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'captured-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<string,number>();let cursor=0,header=false;
while(true){
if(signal.aborted)throw fault('copy_aborted');const start=cursor,line=await source.read(cursor,signal);if(!line)break;cursor=line.nextCursor;
const value=line.value as unknown as {kind:string;version?:number;projectId?:string;record:never};if(line.error||line.skip||!value)throw fault('invalid_copy_snapshot');
if(!header){if(value.kind!=='header'||value.version!==1||value.projectId!==projectId)throw fault('invalid_copy_snapshot');header=true;continue;}
// 分派只检查行种类;canvas/element 的重复 ID、结构和 schema 留给后续工厂与完整文档检查。
if(value.kind==='canvas')canvases.push(value.record);else if(value.kind==='element')elements.push(value.record);else if(value.kind==='tombstone'){const record=value.record as [string,number];if(!Array.isArray(record)||record.length!==2||tombstones.has(record[0]))throw fault('invalid_copy_snapshot');tombstones.set(record[0],record[1]);}else throw fault('invalid_copy_snapshot');
await budget.step(Math.max(1,Math.ceil((cursor-start)/1024)));
}
@@ -38,7 +44,10 @@ export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Re
}
/** Copy declarations are a versioned input; never silently reinterpret a persisted preparation. */
/** 仅对源涉及组件的数据版本/structure/copy声明生成稳定签名,防持久准备被新声明静默重解释。 */
/** 仅对源涉及组件的数据版本/structure/copy声明生成签名,防持久准备被新声明静默重解释。
* Set 去重并排序 componentId,让元素遍历顺序不影响签名;缺失定义也明确编码为 missing。
* 结果是 JSON 文本而非加密摘要;copy 内部键序沿用定义,不覆盖 schema/handler 函数或整个 registry。
* 比较与拒绝发生在消费此签名的任务流程,本函数不锁定 registry 或校验源文档。 */
export function copyRegistrySignature(document:Document,registry:Registry):string{
const ids=new Set([...document.elements.values()].map(element=>element.componentId));
return JSON.stringify([...ids].sort().map(id=>{const definition=registry.components.get(id);return definition?{id,dataVersion:definition.dataVersion,structure:definition.structure,copy:definition.copy??null}: {id,missing:true};}));
+19 -9
View File
@@ -1,7 +1,7 @@
/**
* 为新建图片保留一次可恢复的自动默认尺寸授权。解码/服务元数据异步到达后,只有创建历史、
* 目标身份/类型、资源引用及初始视觉尺寸仍有效时才允许调整;实际文档/历史改写走专用 planner。
* 用户已提交的手动 resize 撤销授权,即使以后 undo 回到初始尺寸也不自动复活。
* 已判为用户 resize 的授权转为 revoked,正常后续 undo 不自动复活;本模块不负责执行异步 resize 或保存磁盘。
*/
import type { Commit, CoreSnapshot, Id, Registry, Target } from '../canvas-core/types.ts';
import { closeNumber, unitScale } from '../canvas-core/units.ts';
@@ -9,7 +9,9 @@ import { fault, freeze, readMap } from '../canvas-core/values.ts';
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
import type { ResourceRecord } from './resources.ts';
/** 目标代次/创建entry/组件版本和初始视觉尺寸共同限定授权,status/code 描述可恢复结果。 */
/** 以目标代次/创建 entry/组件版本和 width,height÷unitScale 的初值限定一次自动尺寸授权。
* pending 等候元数据/调度,blocked 可由引擎显式重试,applied/revoked 不再由本检查器自动执行。
* 这些状态不等同上传状态或另一个 undo 单元,也不单独证明用户权限/创建历史仍可用。 */
export type DefaultSizeGrant = {
readonly target: Target; readonly entryId: Id; readonly componentId: string; readonly dataVersion: number;
readonly initialSize: { readonly width: number; readonly height: number };
@@ -18,7 +20,9 @@ export type DefaultSizeGrant = {
/** 每资源的版本化授权列表;图片副本可有多个目标,各 grant 独立判断。 */
export type DefaultImageSizing = { readonly version: 1; readonly grants: readonly DefaultSizeGrant[] };
/** 校验解释器、目标/entry/尺寸/状态,并禁止同一 ID+incarnation 的重复 grant。 */
/** 只验 v1、目标/entry/组件版本、正有限初始尺寸、状态/code 与单资源内目标代次唯一性。
* 不查真实 document/history/resource,不证明所有 grant 有效,也不捕获/冻结输入或要求尺寸为整数。
* 未知版本报解释器不可用;保留未来 opaque sizing 的兼容策略由恢复边界处理,不在此按 v1 解释。 */
export function validateDefaultImageSizing(value: unknown): asserts value is DefaultImageSizing {
const state = value as DefaultImageSizing;
if (!state || state.version !== 1) throw fault('default_size_interpreter_unavailable');
@@ -34,7 +38,10 @@ export function validateDefaultImageSizing(value: unknown): asserts value is Def
}
}
/** 从当前创建提交与关联 history 捕获授权,只针对引用该 assetId 的已知且可 resize 节点。 */
/** 从有本 commit 操作贡献的 history 项与 commit 创建变化,匹配当前同代次、同 assetId、已知且能 resize 的 node。
* 保存提交后 width/height÷unitScale,不取文件像素/世界坐标;此时尚不需要 stored 回执或解码尺寸。
* entry 按“含 commit 操作 ID”筛选,不逐项证明该 entry 的 changes 恰含该目标创建;调用方须提供真实创建提交。
* 替换 resource.sizing 为本次 grants(可为空),不合并旧 grant 或做重复校验;完整 grant 校验由发布/恢复边界负责。 */
export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSnapshot, commit: Commit, registry: Registry): ResourceRecord {
const grants: DefaultSizeGrant[] = [];
for (const entry of next.history.entries.values()) {
@@ -54,10 +61,11 @@ export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSn
return freeze({ ...resource, sizing: { version: 1, grants } });
}
/** Runs on every publication path. Revocation is durable and does not revive when
* undo happens to return to the default geometry. Preview-only gestures do not publish.
*/
/** 每条 Root 发布路径检查 pending/blocked grant;失效时持久标 revoked,未变化则复用 runtime。 */
/** Return a revocation candidate from each publication path; the engine persists the Root. */
/** 对 next 的 v1 pending/blocked 授权检查身份/类型/资源、创建项仍在 undo、归一化尺寸和新用户意图。
* 失效则返回带 revoked/code 的 copy-on-write runtime,不执行 resize/网络/磁盘;未变化复用 next.runtime。
* applied/revoked 输入直接保留,未来版本不解释;不独自阻止受信调用方手动改写这些状态,状态所有权由引擎管理。
* 不读取 registry/手势/解码结果,真正消费 grant 的资格与尺寸计划由引擎随后复核。 */
export function prepareDefaultImageSizing(previous: EngineSnapshot, next: EngineSnapshot): RuntimeState {
let resources = next.runtime.resources;
for (const [id, resource] of resources) {
@@ -76,7 +84,9 @@ export function prepareDefaultImageSizing(previous: EngineSnapshot, next: Engine
if (!closeNumber(element.geometry.width / units, grant.initialSize.width) || !closeNumber(element.geometry.height / units, grant.initialSize.height)) code = 'user_resized';
// Detect committed resize-away-and-back transactions. Pure coordinate
// conversions may alter geometry sizes and unitScale together.
// 已提交事务即使最终尺寸回原值,也检查用户 geometry changes;纯 owner/unit 转换例外。
// 在文档变化且 operations 数量增加时扫描新 user 意图,识别 resize-away-and-back;假设操作表按正常发布追加。
// 同一意图含 owner 字段或 unitScale 变化就整体按转换豁免此附加检查;不逐轴拆分任意混合转换/手动缩放。
// 净归一化尺寸已改变仍会在上方撤销;纯预览不发布 operations,因此不会因 pointermove 单独触发。
if (!code && previous.document !== next.document && previous.operations.size !== next.operations.size) {
for (const [operationId, intent] of next.operations) {
if (previous.operations.has(operationId) || intent.origin !== 'user') continue;
+10 -2
View File
@@ -12,11 +12,15 @@ import type {StorageLeader,StoragePort} from './storage-port.ts';
import {resourceRetentionPins} from './resource-retention.ts';
/** Derive one project's shared-byte manifest from the exact Root being persisted. */
/** 生成已确认上传/跨项目 binding 的共享身份、所需字节与保留清单;无 depot 能力明确拒绝。 */
/** 从指定不可变 snapshot 派生共享身份/字节写入项与 pins,缺少 assetDepot 端口则拒绝。
* 会异步读取 depot/本地文件;不提交存储事务、不重算摘要,也不在 await 后自行校验 leader/宿主身份。
* 返回可变描述数组而非冻结 Root;调用方须把清单与该 snapshot 同次提交,rootRevision 不另存于返回值。
* snapshot 和各输入缓存需遵循宿主捕获约定,尤其 adoptedFiles 用 revision 防止混入较晚的采纳。 */
export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{
const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[];
if(!storage.assetDepot)throw fault('asset_depot_unavailable');
// 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。
// 每个 stored+digest 资源都登记身份(包括已 purge 的项目记录),但只有仍保留的才尝试补字节。
// sharedAssetRef 校验并捕获元数据,随后要求资产 ID/长度对应本地记录;未上传资源不进入共享写入清单。
for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){
const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored});
if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]);
@@ -25,6 +29,8 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){
const cached=await storage.assetDepot.read(leader,authority,resource.id);
// The store enforces immutable identity even when bytes are already present.
// 缓存已有字节则只提交身份;否则优先项目内存文件,再读项目存储。
// 两处均无字节时仍可形成仅身份写入项;是否满足事务要求、摘要与不可变身份由 adapter 检查。
if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id);
}
writes.push({ref,...(bytes?{bytes}:{})});
@@ -34,9 +40,11 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]);
const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})});
}
// binding 不以 pins 决定是否写入,只有其暂存 bytes 的采纳 revision 不晚于本 Root 才携带字节。
// A source/run envelope may refer to resources outside this project's upload table.
// Its complete resource interpreter must be installed before global deletion is allowed.
// 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。
const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0;
// complete 是共享仓库全局删除所需的覆盖声明,不是字节写入完成回执;真正删除由仓库协议决定。
return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes};
}
+11 -4
View File
@@ -8,7 +8,9 @@ import {importSourceDigest} from './import-integrity.ts';
/** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */
export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void};
/** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */
/** 默认2并发/64等待,上限8并发;可注入 Worker/native/测试执行器,默认使用分块摘要。 */
/** 默认 2 并发/64 等待,上限 8 并发;可注入 execute,默认 importSourceDigest 分块计算。
* maxQueued 只计算 waiting,不含 active;不限制 Blob 大小/MIME,不做相同文件去重或结果缓存。
* execute 负责计算真实完整摘要,池仅用正则检查输出格式,不能证明输出确实对应输入字节。 */
export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise<string>;concurrency?:number;maxQueued?:number}={}):DigestPort{
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest;
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget');
@@ -16,9 +18,13 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean};
// Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
/** 只 settle 一次并移除等待项/监听;故意不在这里 active.delete,迟到结果不能二次采纳。 */
/** 只 settle 一次并移除 waiting/外部监听,不在这里 active.delete,迟到结果不能二次采纳。
* error 用真值分支,execute 应以 Error 等真值拒绝;falsy rejection 不保证被当成失败处理。 */
function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);}
/** 有空槽就取最早等待项,先登记 active 再微任务执行;结果校验/取消后 finally 归还真实槽。 */
/** 从 Map 首项 FIFO 取任务,先占 active 再微任务调用 execute;启动前、结果返回后均复查 abort。
* execute 的拒绝由第二个 then 回调处理,结果校验抛错再由 catch 处理,最终统一归还槽位并泵下一项。
* 正则会按 JS 规则强制转字符串,未另做 typeof 检查;注入执行器应遵守 Promise<string> 合同。
* 无视 abort 且不 settle 的执行器会一直占槽;池没有超时或强制终止通用执行器的能力。 */
function pump(){
if(closed)return;
while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);
@@ -31,7 +37,8 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity'));
return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});
},
/** 幂等关闭,取消等待/活跃任务的调用结果;无视 signal 的执行仍由其 Promise 收尾后释放 active。 */
/** 幂等关闭并取消 waiting/active 的调用结果,停止接受新任务;不返回 drain Promise。
* 无视 signal 的执行仍由自身 Promise 收尾后释放 active,close 返回不证明底层计算已停止。 */
close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}},
});
}
@@ -11,7 +11,10 @@ export const nativeDigestByteLimit=64*1024*1024;
/** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread.
* The host still owns cancellation by terminating the request's Worker. */
/** 验证 Blob,按能力/大小选 native 或增量实现,均返回相同的完整原字节摘要。 */
/** 验证 Blob,SubtleCrypto 存在且大小 ≤64MiB 时读取整个 ArrayBuffer 做 SHA-256,否则流式计算。
* 原生读/算失败直接传播,不自动切到流式重试;注入 subtle 应遵守原生 digest 的 ArrayBuffer 合同。
* 本函数无 signal,取消靠宿主终止 Worker;代码本身不检测当前线程,Worker 限制由装配调用方遵守。
* 阈值只限制此输入的整缓冲分配,未计 Blob/原生算法内部内存及其它并发 Worker。 */
export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{
if(!(bytes instanceof Blob))throw fault('invalid_digest_input');
// native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。
@@ -20,6 +23,7 @@ export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'di
if(digest.byteLength!==32)throw fault('invalid_digest_response');
return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join('');
}
// Worker fallback 每块1MiB,只做微任务让出;UI 让出策略由 UI fallback 的端口另行决定。
// 每块 1MiB,块间 yieldTask 仅完成一个 Promise 微任务,不保证 Worker 消息队列获得宏任务机会。
// UI fallback 用 importSourceDigest 默认定时器让出;不要把这里的微任务策略移作 UI 响应性承诺。
return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()});
}
+3 -1
View File
@@ -8,7 +8,9 @@ import {digestWorkerSource} from './digest-worker-source.ts';
const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
/** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */
let accepted=false;
/** 校验协议和请求后启动只读摘要;成功/失败都回显输入 ID 与字节数,供宿主核对。 */
/** 首条有效 version=1/正安全整数 ID/Blob 设置 accepted,之后包括失败后的重复消息都忽略。
* 无效请求直接忽略,不返回错误;这里不检查 MIME/大小或资源身份,不拥有持久任务。
* 算法拒绝会尝试回稳定错误;成功回信自身抛错不由同一个 then 的失败回调捕获,宿主仍需错误/取消收尾。 */
scope.onmessage=event=>{
const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true;
void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'}));
+11 -4
View File
@@ -16,12 +16,15 @@ type Entry = { readonly key: string; readonly owner: object; readonly controller
* durable bytes before acquiring. Scope objects are never serialized or reused
* across project engines/auth epochs. There is no idle URL retention.
*/
/** 创建纯显示生命周期容器;owner 应绑定 engine/auth epoch,不能序列化或跨账号复用。 */
/** 创建纯显示生命周期容器,owners 两层 Map 用对象身份+字符串 key 索引共享条目。
* owner 应绑定 engine/auth epoch;本模块不解释账号、资源 ID、URL 权限或实际文件 pin。
* 没有容量上限/空闲保留,最后 lease 释放即清理;永久撤销 owner 则另记到 WeakSet。 */
export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
// owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
let closed = false;
/** 统一调用来源 release 并隔离错误,清理/日志失败不阻止释放其它 URL。 */
/** 同步调用来源 release,隔离其抛错和 onError 的抛错;onError 只报告清理失败。
* load 的失败通过各 lease.ready 传出,不在这里自动记录或重试;release 合同也不是异步清理任务。 */
function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
/** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */
function retire(entry: Entry) {
@@ -41,7 +44,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
}
}
return Object.freeze({
/** 借用显示来源:同 owner/key 合并加载,但每个调用者可独立取消自己的等待。 */
/** 同 owner/key 复用进行中或已加载来源,命中时忽略本次传入的 load,不逐次重验权限。
* 每个调用者独立 ready/release;释放已兑现的 ready 不会改变 Promise 的成功结果,但 URL 可能随最后释放失效。
* 不自动接收 AbortSignal,取消通过 lease.release;单个释放不取消仍有其它消费者的共享加载。 */
acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease {
if (closed || retired.has(owner)) throw fault('display_scope_closed');
if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key');
@@ -55,6 +60,7 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
if (created.retired) throw fault('display_released');
return load(created.controller.signal);
}).then(source => {
// 这里只检查 URL 为非空字符串及可选 release 为函数,不验证 URL 协议/可访问性或图片内容。
if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); }
// 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); }
@@ -70,7 +76,8 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
if (!shared.leases.size) retire(shared);
};
shared.leases.add(release);
// 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。
// 映射共享 promise 到各 lease.ready,失败逐个归还;校验 source 时抛错也沿这条失败路径清理。
// 缓存不吞 ready 的拒绝,消费者需接住取消/加载错误;释放后迟到成功不能再次 resolve 本租约。
const ready = new Promise<string>((resolve, reject) => {
rejectReady = reject;
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
+12 -3
View File
@@ -13,7 +13,10 @@ import { duplicateImportLine } from './import-duplicate.ts';
import type { ImportSource } from './imports.ts';
/** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */
/** 要求未输出的emit起点、当前registry签名及有效绑定,ID工厂在恢复重校验中禁止意外再分配。 */
/** 要求 emit 起点/零输出计数与当前 registry 签名,调用 planner 复核 checkpoint 结构绑定且禁止再分配 ID。
* 映射复制为私有 Map,但 document/registry/state 及 readElement 仍沿用调用方引用,须保持不可变/稳定。
* sourceToken/fingerprint 的真实字节绑定由上层验证;本函数不读取文件或重新计算 SHA。
* 构造后可按 ordinal 任意重读,read 不推进 state.emitted;持久进度由 runner 的 job.cursor 保存。 */
export function createDuplicatePlanSource(document:Document,registry:Registry,state:DuplicateCheckpoint,fingerprint:string,readElement?:(id:Id,signal:AbortSignal)=>Promise<ElementRecord>):ImportSource{
if(state.phase!=='emit'||state.emitted!==0||state.emittedEdges!==0||state.schemaToken!==copyRegistrySignature(document,registry))throw fault('copy_interpreter_changed');
// Structural/source binding checks; the decoded source has already passed document validation.
@@ -22,7 +25,9 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
const elements=new Map(state.elementIds),canvases=new Map(state.canvasIds),copiedCanvases=new Set(state.canvases),keys=new Map<Id,string>(),reverseElements=new Map(state.elementIds.map(([id,mapped])=>[mapped,id])),reverseCanvases=new Map(state.canvasIds.map(([id,mapped])=>[mapped,id]));
for(const[id,mapped]of elements)keys.set(mapped,`element:${id}`);for(const[id,mapped]of canvases)keys.set(mapped,`canvas:${id}`);
// Edge geometry needs mapped endpoint/owner records. Compute these on demand; retain a bounded cache.
/** 给边计算提供新端点/owner视图,get按需转换并只保留32条缓存,不批量生成所有副本对象。 */
/** 给边计算提供原表+映射表的只读投影,get 按需转换映射 ID,每个投影最多缓存 32 项。
* 淘汰按插入顺序,缓存命中不刷新顺序,因此是 FIFO 而非 LRU;不缓存 readElement 的完整 payload。
* size/迭代将两组成员拼接,依赖映射不与原 ID 碰撞的 planner 合同,不能当可发布的规范文档表。 */
function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{
// 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。
const cache=new Map<Id,T>();const get=(id:Id):T|undefined=>{const old=reverse.get(id);if(!old)return base.get(id);const found=cache.get(id);if(found)return found;const original=base.get(old);if(!original)throw fault('invalid_copy_stream_checkpoint');const record=duplicateTreeChange(document,registry,kind,original,elements,canvases,copiedCanvases,state.offset).after as T;cache.set(id,record);if(cache.size>32)cache.delete(cache.keys().next().value!);return record;};
@@ -32,7 +37,11 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
}
// projected只服务边端点和公共owner的坐标计算;length是tree order+edges的固定ordinal总数。
const projected:Document={...document,elements:projection(document.elements,reverseElements,'element'),canvases:projection(document.canvases,reverseCanvases,'canvas')},length=state.order.length+state.edges.length;
/** read按ordinal选择共享tree/edge转换器;单记录错误返回error,源指纹/完整性与调度另由宿主约束。 */
/** 前 state.order.length 项用树转换器,其余用边转换器,生成同一预映射 CopyLine。
* readElement 可按需异步加载元素;其身份/源一致性由注入端口保证,canvas 直接从已捕获文档取。
* 预先取消/非法 ordinal 在 try 外拒绝;try 内失败(含读取后取消)转成 ImportLine.error,runner 必须复查信号。
* 成功检查单行 JSON 的 UTF-8 长度 ≤256KiB(不含 NDJSON 换行),不限制完整输出总字节或在单条内让出。
* 失败不会推进此源的全局游标;是否保留失败、跳过或停止由 runner 管理。 */
return Object.freeze({fingerprint,parserVersion:2,async read(cursor:number,signal:AbortSignal){
if(signal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(cursor)||cursor<0||cursor>length)throw fault('invalid_import_cursor');if(cursor===length)return null;
const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`;
+17 -4
View File
@@ -23,7 +23,10 @@ export type DuplicateScheduling = {
* No document publication or network effects. The caller must persist source/mapping and
* revalidate external identities before applying a plan; this is not the import executor.
*/
/** 分片消费完整plan迭代器,每片默认6ms/512步;返回前全部记录已生成并校验,但不执行导入。 */
/** 分片消费完整 plan 生成器,默认每片 6ms/512 个语义步,完成后返回全量计划而非逐条发布。
* checkpoint 仅接收累计步数,不含映射/生成器状态,不能用该数字恢复规划;最终返回前不保证再调用一次。
* 每次 next 本身可包含同步固定成本,软预算不能抢占它;now/yieldTask 决定实际调度,默认 timer 让出。
* 异常/取消关闭生成器,不回滚已调用 createId 或 checkpoint 的外部副作用;调用方负责可靠源/映射保存。 */
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
@@ -58,7 +61,11 @@ export async function planDuplicateAsync(document: Document, registry: Registry,
}
/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */
/** 以完整checkpoint恢复语义游标,仍重做源验证/派生输出缓存,不声称从任意字节零重算。 */
/** 从完整 DuplicateCheckpoint 恢复语义游标/已分配 ID,源验证和派生输出缓存仍由 planner 重建。
* 每个未完成时间片可将不可变 checkpoint 交宿主 await;回调成功只代表宿主 Promise 成功,不独自证明落盘。
* planner.step 完成后直接 result,不额外回调最终状态;需要最终可靠切点时由调用方保存返回计划/状态。
* selection/偏移/映射在入口捕获,document/registry 沿不可变快照合同使用;调度选项应保持稳定。
* 与完整生成器入口一样,不发布业务文档、不补偿已分配 ID 或宿主 checkpoint 副作用。 */
export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -73,7 +80,12 @@ export async function planDuplicateResumable(document:Document,registry:Registry
/** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */
/** 只推进到emit起点:范围/顺序/全部新ID已固定,但尚未转换全部payload,可交lazy source逐条输出。 */
/** 仅推进至 ready/emit 起点,返回顺序/映射已固定的 checkpoint;尚未完成所有 payload 转换与输出校验。
* planner 捕获小输入与私有语义 state;此层只调度 step/checkpoint,不用另一个 live 文档替换源。
* checkpointIntervalMs=0 默认每个未完成切片保存;>0 时按阶段/首次分配/时间/步数门槛节奏化保存。
* 阈值只在切片末检查,maxStepsPerCheckpoint 可能多跨一片;不是逐语义步的硬持久间隔。
* ready 时只有 interval>0 且有回调才补一次保存;默认路径可直接返回,宿主仍须持久化返回状态。
* topologyOnly 仅选择源拓扑检查,不证明全部组件 payload 可复制;懒源逐条转换时仍可报记录失败。 */
export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -82,7 +94,8 @@ export async function prepareDuplicateStream(document:Document,registry:Registry
const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192;
if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget');
const planner=createDuplicatePlanner(document,registry,selection,options,binding);
// savedSteps等只记录上次已等待checkpoint回调成功的位置,保存耗时不冒充新增规划工作。
// saved* 是调度内存,不是另一份持久 checkpoint;首次 savedPhase 未知会在首片触发回调。
// savedAt 只在回调成功后重取,保存耗时不算新增规划工作,也不会单因慢存储强迫下一片立即再存。
let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now();
while(true){
const start=now();let count=0;

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