docs(canvas): explain recovery and import UI lifecycle
This commit is contained in:
@@ -11,6 +11,7 @@ export function DraftRecovery({ restore, discard, canRestore = true, exportDraft
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
|
||||
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
||||
// 点击按钮只同步调用宿主,不自动 close,也没有 busy/重复点击互斥;宿主完成选择后负责卸载对话框。
|
||||
// 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
|
||||
|
||||
@@ -22,13 +22,17 @@ export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectE
|
||||
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
|
||||
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
|
||||
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
||||
// scope 只比较 engine/canvas,不带挂载代次;同一目标卸载后的已兑现回调并非由此 ref 自动取消。
|
||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
|
||||
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
|
||||
// control 接收已经开始的 Promise:同步调用抛错不在它的 catch 内;这里也不阻止多次点任务按钮。
|
||||
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
|
||||
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
|
||||
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
|
||||
// 64KiB 只是后缀为 .json 时的格式探测;真正解析、校验、文件持久保存和逐步加入由 runtime 负责。
|
||||
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
|
||||
// “重试源文件校验/保存”调用 engine.retry,作用于项目待重试工作,不是只重试当前展示的 job。
|
||||
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
|
||||
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
|
||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
|
||||
|
||||
@@ -66,6 +66,7 @@ function PlanStructureReview({plan}:{plan:ConflictPlan}){
|
||||
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
|
||||
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
|
||||
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
|
||||
// busy 是 React 状态护栏,不是同步锁;真正的会话互斥与持久屏障仍由 engine 校验。
|
||||
// busy防重复动作;frozen在开始应用后锁住选择,保存异常时允许原方案重试,明确拒绝才解除冻结。
|
||||
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
||||
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
|
||||
@@ -75,13 +76,16 @@ export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngi
|
||||
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
|
||||
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
|
||||
},[engine]);
|
||||
// cancel/apply 的 await 之后不另核对 live 或 engine 代次;宿主应按 engine 生命周期挂载此面板。
|
||||
/** 已有session且正在操作时不允许取消;否则等待engine解除恢复屏障,成功才关闭,失败留窗提示。 */
|
||||
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
/** 有plan则resolve逐项方案,无待处理操作则apply采用最新根;拒绝结果展示原因,异常保留冻结方案供重试。 */
|
||||
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
// plan 是会话生成的本地预览,不等于已发布/已持久化;只有 resolve/apply 的结果决定是否成功关闭。
|
||||
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
|
||||
const canonical=session?.canonical.document;
|
||||
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
|
||||
// records 是准备完成时的一次操作清单;面板不逐帧订阅 ledger,恢复屏障负责保持会话前提。
|
||||
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
|
||||
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
|
||||
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
|
||||
|
||||
@@ -37,6 +37,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
|
||||
const read=()=>readCanvasMigration(localStorage,scope);
|
||||
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
|
||||
// inspect 没有独立的在途 Promise 合并;storage/刷新可重复调用,跨页互斥由 Web Locks 与持久记录保证。
|
||||
async function inspect(){
|
||||
if(attempt.current||!current())return;
|
||||
setChecking(true);
|
||||
@@ -67,6 +68,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
refresh.current=inspect;void inspect();
|
||||
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
|
||||
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
|
||||
// storage 事件来自其它文档,本页 setItem 不靠此事件更新 UI;也未按 storageArea 区分其它 Storage 来源。
|
||||
const storage=(event:StorageEvent)=>{
|
||||
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
|
||||
// Never discard another view's in-memory content because a record vanished.
|
||||
@@ -78,6 +80,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
};
|
||||
window.addEventListener('storage',storage);
|
||||
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
|
||||
// release 在此是尽力异步清理,不等待也不安装 catch;它的拒绝不进入面板 error 状态。
|
||||
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
|
||||
// The port ref follows renders without re-acquiring a shared editing lease.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -85,6 +88,7 @@ export function LegacyCanvasMigration(props:Props){
|
||||
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
|
||||
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
|
||||
async function upgrade(){
|
||||
// attempt 是同步 ref,先于首次 await 设置,区别于仅用于渲染的 running 状态。
|
||||
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
|
||||
attempt.current=true;setRunning(true);setError('');
|
||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||
|
||||
Reference in New Issue
Block a user