diff --git a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md index 80c54121..72d95eff 100644 --- a/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md +++ b/design/03-rust-web/process/3-20-browser-runtime-followup-checklist-v1.md @@ -6,7 +6,7 @@ ## 1. 目标 -在 `3-19` 已归档的基础上,继续把仍留在 Rust inline / 主壳大 runtime 中的浏览器行为切成可审计模块。Codex 维护本文件作为事实源,Reasonix 只承接短批次审计、窄范围实现或浏览器 smoke;每批最多 4 个 worker。 +在 `3-19` 已归档的基础上,继续把仍留在 Rust inline / 主壳大 runtime 中的浏览器行为切成可审计模块。Codex 维护本文件作为事实源,Reasonix 只承接短批次审计、窄范围实现或浏览器 smoke;每批最多 2 个 worker。 ## 2. 本轮边界 @@ -63,7 +63,7 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 ## 6. 本轮执行记录 - 2026-05-25:Codex 创建本续跑事实源。 -- 2026-05-25:修正执行约束:本文件遵循当前 goal 的“每批最多 4 个 worker”;实现 worker 必须在独立 worktree 产出候选 patch,Codex 复核后再选择性合入。 +- 2026-05-25:修正执行约束:本文件遵循当前 goal 的“每批最多 2 个 worker”;实现 worker 必须在独立 worktree 产出候选 patch,Codex 复核后再选择性合入。 - 2026-05-25:下一批只派发 Worker B(local upload / filetree 下一刀只读审计);Worker A/C/D 暂缓。 - 2026-05-25:Reasonix local upload / filetree 只读审计尝试未产生 `result.json` / `process-handoff`,且运行期间出现额外 Batch A2 runner,主控已终止进程;该结果不作为正式 handoff 采纳。 - 可参考的 transcript 结论:`fetchWithTimeout` 是低风险纯工具函数,可先迁入 `local-upload-runtime.js`;`uploadFileToMediaAsset` / `uploadFilesWithResolvedTarget` / `insertUploadedAssetIntoEditor` 仍与 API、editor 插入和 snapshot refresh 耦合,暂不迁。 @@ -92,7 +92,7 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 - 修改:`rust/crates/mnote-web/browser/document-conflict-panel-runtime.js`、`rust/crates/mnote-web/src/routes/web_shell.rs`。 - 已验证:`node --check rust/crates/mnote-web/browser/document-conflict-panel-runtime.js`、`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/Cargo.toml -p mnote-web`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web document_conflict_panel_runtime_contains_dom_helpers -- --test-threads=1`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1`。 - 未完成:conflict merge / accept / keep 业务逻辑、document session lifecycle、secondary pane conflict smoke 仍未迁出;本轮未声称真实冲突 UI 全链路完成。 -- 2026-05-25:根据用户纠正,后续实现 worker 不能在主工作区直接修改;每批最多 4 个 worker,且必须采用独立 worktree 候选 patch 或明确只读任务。Codex 读取 Hindsight recall、`process-handoff.md/json`、`result.json`、diff 与验证证据后再选择性合入。 +- 2026-05-25:根据用户纠正,后续实现 worker 不能在主工作区直接修改;每批最多 2 个 worker,且必须采用独立 worktree 候选 patch 或明确只读任务。Codex 读取 Hindsight recall、`process-handoff.md/json`、`result.json`、diff 与验证证据后再选择性合入。 - 2026-05-25:Codex 主控完成非本地 media upload API 小切片:`local-upload-runtime.js` 新增并导出 `uploadMediaAsset(file, plan, context)`,承接 `/api/media/upload` 的 FormData 构造、fetch 和响应解析;`layout.rs` 的 `uploadFileToMediaAsset` 仍负责 appendUploadedAssetRow、editor 插入和 `wolai:assets-changed` 事件分发,并保留完整 inline fallback。 - 修改:`rust/crates/mnote-web/browser/local-upload-runtime.js`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。 - 已验证:`node --check rust/crates/mnote-web/browser/local-upload-runtime.js`、`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/Cargo.toml -p mnote-web`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_upload_runtime_contains_editor_upload_context_helpers -- --test-threads=1`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder -- --test-threads=1`、`node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`。 @@ -124,8 +124,12 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 - 2026-05-25:Batch E 派发异常并终止:Worker B 任务被重复启动为两个 runner / ACP 进程(`reasonix-2026-05-24T18-25-26-844Z-33ea4d58`、`reasonix-2026-05-24T18-26-04-453Z-5ff62e40`),同一 worktree `/mnt/Data1T/mnote-worktrees/0525-e-worker-b-3-20-conflict-diff-dom` 出现并发写入风险;Codex 已终止相关 Reasonix 进程,未读取到 `final.md`、`result.json`、`process-handoff.md/json`。原始未验证 diff 不直接采纳;后续采纳必须经过 Codex 独立复核、修正和验证。 - 2026-05-25:Batch F 计划继续采用独立 worktree 候选 patch:Worker B 负责把 file tree 上传目标解析的纯 fallback / plan 归一化 helper 外置到 `filetree-runtime.js`;`layout.rs` 仍保留 async preflight 和完整 inline fallback,避免和当前 conflict panel 写集冲突。 - 2026-05-25:Batch F Worker B 异常终止:run `reasonix-2026-05-24T18-39-34-442Z-d9e7ada1` 只产出 `prompt.md` / `memory-recall.json` / `reasonix-transcript.jsonl`,未产出 `final.md`、`result.json`、`process-handoff.md/json`,worktree 无 diff;transcript 停在 `ask_choice`,原因是 worker 误判任务书拼接了 Worker A / Worker B 两个任务并要求人工选择。Codex 已终止 runner / ACP,不采纳该 run。 -- 2026-05-25:Batch G 超限派发并终止:Worker B/C/D 三个 Reasonix runner 同时启动,超过当前 goal 的“每批最多 2 个 worker”;run `reasonix-2026-05-24T18-51-00-477Z-1307a1b4`、`reasonix-2026-05-24T18-51-00-504Z-2304f570`、`reasonix-2026-05-24T18-51-00-541Z-17d61f43` 均只有 `prompt.md` / `memory-recall.json` / `reasonix-transcript.jsonl`,无 handoff,worktree 无 diff;Codex 已终止 runner / ACP,不采纳该批。 +- 2026-05-25:Batch G 按用户纠正把 Worker B/C/D 分别派到独立 worktree,但三个 worker 同时启动,超过当前 goal 的“每批最多 2 个 worker”;三个 Reasonix run `reasonix-2026-05-24T18-51-00-477Z-1307a1b4`、`reasonix-2026-05-24T18-51-00-504Z-2304f570`、`reasonix-2026-05-24T18-51-00-541Z-17d61f43` 均只有 `prompt.md` / `memory-recall.json` / `reasonix-transcript.jsonl`,无 handoff/result/final,worktree 初始 diff 为空;Codex 不采纳这些 Reasonix run,只把独立 worktree 保留为 Codex 候选 patch 沙盒。 - 2026-05-25:Codex 主控完成 filetree upload target fallback 小切片:`filetree-runtime.js` 新增并导出 `fileTreeUploadTargetFallback(detail, deps)`,承接同步 fallback / plan 归一化;`layout.rs` 的 `fallbackFileTreeUploadTarget` 优先委托 runtime,仍保留完整 inline fallback;async preflight、后端归属判定、`uploadFileToMediaAsset`、`uploadFilesWithResolvedTarget` 不迁。 - 修改:`rust/crates/mnote-web/browser/filetree-runtime.js`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。 - 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js`、`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/Cargo.toml -p mnote-web`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree -- --test-threads=1`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_upload_runtime_contains_editor_upload_context_helpers -- --test-threads=1`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`。 - 未完成:async preflight、`refreshLocalFolderSidebarSnapshot` / `appendUploadedAssetRow`、conflict action helpers、secondary pane conflict smoke 仍未迁出或覆盖。 +- 2026-05-25:Batch G Worker C 使用独立 worktree `/mnt/Data1T/mnote-worktrees/0525-g-worker-c-3-20-conflict-actions`;Reasonix run `reasonix-2026-05-24T18-51-00-504Z-2304f570` 无 handoff/result/final,且 transcript 停在 `ask_choice`,不采纳。Codex 随后在该 worktree 产出候选 patch,经复核后选择性移植到主工作区。 + - 修改:`document-conflict-panel-runtime.js` 新增并导出 `runSessionConflictAction(action, deps)`,只负责 promise/catch 动作包装;`web_shell.rs` 仍持有 `acceptDiskVersion`、`keepCurrentEditorVersion`、`writeMergedConflictResult`、status 和重新渲染业务逻辑。 + - 已验证:`node --check rust/crates/mnote-web/browser/document-conflict-panel-runtime.js`、`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/Cargo.toml -p mnote-web`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web document_conflict_panel_runtime_contains_dom_helpers -- --test-threads=1`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`。 + - 未完成:`acceptDiskVersion` / `keepCurrentEditorVersion` / `writeMergedConflictResult` 业务逻辑、document session lifecycle、secondary pane conflict smoke 仍未迁出或覆盖。 diff --git a/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md b/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md index 7f3a85cb..83cce27c 100644 --- a/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md +++ b/design/05-editor-mainline/process/5-29-editor-runtime-followup-checklist-v1.md @@ -67,7 +67,7 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 - 已迁函数:`node_is_within_root`、`active_editor_text_selection`、`has_active_text_selection`、`selection_summary`、`selection_has_rich_marks`。 - 未迁:`SelectionPayload`、`selection_payload`、`send_selection_state*`、`selection_event_payload`,原因是仍依赖 bridge event、`HoveredBlockState` 和 runtime block id 边界。 - 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen`、`task488`、`task489`、CodeGraph pending 0。 -- 2026-05-25:执行口径修正:继续遵循当前 goal 的“每批最多 4 个 worker”;Reasonix 后续必须使用独立 worktree 或明确只读任务,完成后由 Codex 读取 handoff 并复核。 +- 2026-05-25:执行口径修正:继续遵循当前 goal 的“每批最多 2 个 worker”;Reasonix 后续必须使用独立 worktree 或明确只读任务,完成后由 Codex 读取 handoff 并复核。 - 2026-05-25:Batch D 额外 runner 曾自动启动 Worker A / Worker B,未经过本轮主控计划确认;主控已终止相关 runner / ACP 进程,不采纳这些未受控 run。遗留 task 草稿暂不删除,后续如继续使用必须重新生成受控任务书。 - 2026-05-25:Reasonix runner 异常:后续实现 run 未写 `result.json`、`process-handoff.md/json`,部分 transcript 停在工具调用阶段;Codex 不采纳其文字结论,只复核实际 diff。 - 2026-05-25:Reasonix overlay / block menu 只读审计尝试未产生 `result.json` / `process-handoff`,且运行期间出现额外 Batch A2 runner,主控已终止进程;该结果不作为正式 handoff 采纳。 @@ -120,3 +120,12 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据 - 修改:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_menu_overlay.rs`、`rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs`、`rust/spikes/leptos-tiptap-spike/src/lib.rs`、generated island wasm/js/d.ts。 - 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js`。 - 未完成:`pointer_in_handle_corridor`、`drop_indicator_from_target` / `drop_indicator_from_point`、`sync_editor_overlay_state` / `try_sync_editor_overlay_state` 仍在 `lib.rs`。 +- 2026-05-25:Batch G Worker D 先按用户纠正使用独立 worktree `/mnt/Data1T/mnote-worktrees/0525-g-worker-d-5-29-block-dnd-helpers`;Reasonix run `reasonix-2026-05-24T18-51-00-541Z-17d61f43` 仅产出 `prompt.md` / `memory-recall.json` / `reasonix-transcript.jsonl`,无 handoff/result/final,worktree 初始 diff 为空,拒绝作为 Reasonix handoff 采纳。Codex 随后在该独立 worktree 产出候选 patch,经复核后选择性移植到主工作区。 + - 修改:新增 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs`,迁出 `pointer_in_handle_corridor` / `drop_indicator_from_target` 使用的纯几何 helper;`lib.rs` 仍保留 DOM 查询、signal 编排、拖拽事件和 HTML reorder 行为。 + - 已验证:`cargo fmt --manifest-path rust/Cargo.toml --all --check`、`cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`、wasm release build、`wasm-bindgen`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task489-block-menu-delete-undo-smoke.js`、`NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task488-local-attachment-link-delete-undo-smoke.js`。 + - 未覆盖:`cargo test --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml block_dnd -- --test-threads=1` 仍被既有 `STYLE` 测试编译错误阻断,未进入本次新增 helper 测试。 + - 未完成:`drop_indicator_from_point` 的 DOM target lookup、拖拽 listener 编排、`sync_editor_overlay_state` / `try_sync_editor_overlay_state` 仍在 `lib.rs`。 +- 2026-05-25:Batch H 只派发 1 个只读 Reasonix worker 复核 block DnD helper 边界,使用独立 worktree `/mnt/Data1T/mnote-worktrees/0525-h-5-29-block-dnd-readonly`,run id `reasonix-2026-05-24T18-59-12-393Z-b5d95a7b`,并显式使用 `--no-memory-recall` 降低任务串线风险。 + - Codex 已读取 Hindsight recall、`process-handoff.md/json`、`result.json`,并核对 worktree `git status --short` / `git diff --stat` 为空。 + - 审计结论:只迁纯几何 helper 是合理边界;DOM 查询、`document.element_from_point`、event listener、Leptos signal 和拖拽重排行为继续留在 `lib.rs`。 + - 主控裁决:采纳审计方向,但保留当前 `editor_runtime/block_dnd.rs` 的 `HandleCorridorGeometry` 参数结构,避免 Reasonix 建议的长参数列表降低可读性。 diff --git a/rust/crates/mnote-web/browser/document-conflict-panel-runtime.js b/rust/crates/mnote-web/browser/document-conflict-panel-runtime.js index 112553d6..c7a35ff5 100644 --- a/rust/crates/mnote-web/browser/document-conflict-panel-runtime.js +++ b/rust/crates/mnote-web/browser/document-conflict-panel-runtime.js @@ -167,8 +167,24 @@ function populateSessionConflictDiffPanel(panel, context, deps) { }); } +function runSessionConflictAction(action, deps) { + deps = deps || {}; + if (typeof action !== 'function') return; + try { + var result = action(); + if (result && typeof result.then === 'function') { + result.catch(function(error) { + if (typeof deps.onError === 'function') deps.onError(error); + }); + } + } catch (error) { + if (typeof deps.onError === 'function') deps.onError(error); + } +} + window.__mnoteDocumentConflictPanelRuntime = { clearSessionConflictSurface: clearSessionConflictSurface, createSessionConflictPanel: createSessionConflictPanel, - populateSessionConflictDiffPanel: populateSessionConflictDiffPanel + populateSessionConflictDiffPanel: populateSessionConflictDiffPanel, + runSessionConflictAction: runSessionConflictAction }; diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 4e2cf922..4aa4501a 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -2122,28 +2122,40 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { await persistSession(session); }; + const runSessionConflictAction = (action, onError) => { + const runtime = window.__mnoteDocumentConflictPanelRuntime; + if (runtime && typeof runtime.runSessionConflictAction === 'function') { + runtime.runSessionConflictAction(action, { onError }); + return; + } + try { + const result = action(); + if (result && typeof result.then === 'function') result.catch(onError); + } catch (error) { + onError(error); + } + }; + const renderSessionConflictSurface = (session, message) => { clearSessionConflictSurface(session); sessionViews(session).forEach((view) => { const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; if (!(host instanceof HTMLElement)) return; const runtime = window.__mnoteDocumentConflictPanelRuntime; + const handleConflictActionError = (error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }; let panel = null; if (runtime && typeof runtime.createSessionConflictPanel === 'function') { panel = runtime.createSessionConflictPanel(session, message, { externalConflictMessage, conflictSourceLabel, onAcceptDisk: () => { - acceptDiskVersion(session).catch((error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }); + runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); }, onKeepCurrent: () => { - keepCurrentEditorVersion(session).catch((error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }); + runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); }, onOpenDiff: (_session, createdPanel) => { openConflictDiffPanel(session, createdPanel); @@ -2189,16 +2201,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { panel.append(heading, text, meta, actions, diffPanel); acceptDisk.addEventListener('click', () => { - acceptDiskVersion(session).catch((error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }); + runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); }); keepCurrent.addEventListener('click', () => { - keepCurrentEditorVersion(session).catch((error) => { - setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); - renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); - }); + runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); }); openDiff.addEventListener('click', () => { openConflictDiffPanel(session, panel); @@ -5659,6 +5665,9 @@ mod tests { assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseCurrent")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseDisk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onSaveMerge")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("runSessionConflictAction: runSessionConflictAction")); } #[tokio::test] diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index d6a81859..ad61ffa8 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs new file mode 100644 index 00000000..69eae8fc --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_dnd.rs @@ -0,0 +1,92 @@ +//! Block handle 拖拽命中的纯几何 helper。 +//! +//! 本模块不查询 DOM、不处理 Leptos signal,也不分发 editor command; +//! 调用方负责传入已经从页面中读取的坐标。 + +use crate::editor_runtime::block_hover_state::DropPlacement; + +pub(crate) struct HandleCorridorGeometry { + pub(crate) stage_left: f64, + pub(crate) stage_top: f64, + pub(crate) block_left: f64, + pub(crate) block_top: f64, + pub(crate) block_bottom: f64, + pub(crate) handle_left: f64, + pub(crate) handle_right: f64, +} + +pub(crate) fn pointer_in_handle_corridor_geometry( + client_x: i32, + client_y: i32, + geometry: &HandleCorridorGeometry, +) -> bool { + let x = f64::from(client_x) - geometry.stage_left; + let y = f64::from(client_y) - geometry.stage_top; + let corridor_left = (geometry.handle_left - 12.0).min(geometry.block_left); + let corridor_right = (geometry.block_left + 18.0).max(geometry.handle_right + 12.0); + + x >= corridor_left + && x <= corridor_right + && y >= geometry.block_top - 18.0 + && y <= geometry.block_bottom + 18.0 +} + +pub(crate) fn drop_placement_from_block_point( + client_y: i32, + block_top: f64, + block_height: f64, +) -> DropPlacement { + let midpoint = block_top + (block_height / 2.0); + if f64::from(client_y) <= midpoint { + DropPlacement::Before + } else { + DropPlacement::After + } +} + +pub(crate) fn drop_indicator_top( + stage_top: f64, + block_top: f64, + block_bottom: f64, + placement: DropPlacement, +) -> f64 { + match placement { + DropPlacement::Before => block_top - stage_top, + DropPlacement::After => block_bottom - stage_top, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pointer_corridor_accepts_handle_lane_and_block_edge() { + let geometry = HandleCorridorGeometry { + stage_left: 100.0, + stage_top: 20.0, + block_left: 140.0, + block_top: 40.0, + block_bottom: 80.0, + handle_left: 108.0, + handle_right: 130.0, + }; + + assert!(pointer_in_handle_corridor_geometry(118, 80, &geometry)); + assert!(pointer_in_handle_corridor_geometry(250, 80, &geometry)); + assert!(!pointer_in_handle_corridor_geometry(90, 80, &geometry)); + assert!(!pointer_in_handle_corridor_geometry(118, 28, &geometry)); + } + + #[test] + fn drop_placement_splits_block_at_midpoint() { + assert_eq!( + drop_placement_from_block_point(50, 40.0, 20.0), + DropPlacement::Before + ); + assert_eq!( + drop_placement_from_block_point(51, 40.0, 20.0), + DropPlacement::After + ); + } +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs index cdafb949..0ff56a26 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod attachment_links; pub(crate) mod attachment_upload; +pub(crate) mod block_dnd; pub(crate) mod block_hover_state; pub(crate) mod block_menu_document; pub(crate) mod block_menu_overlay; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 0a9a05c6..adbde8d9 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -21,12 +21,16 @@ use web_sys::{ }; use editor_runtime::attachment_upload::dispatch_editor_upload_request; +use editor_runtime::block_dnd::{ + drop_indicator_top, drop_placement_from_block_point, pointer_in_handle_corridor_geometry, + HandleCorridorGeometry, +}; use editor_runtime::block_menu_overlay; use editor_runtime::block_menu_legacy_html::{ duplicate_top_level_block_html, reorder_top_level_block_html, }; use editor_runtime::block_hover_state::{ - BlockMenuLayout, DropIndicatorState, DropPlacement, HoveredBlockState, PendingDragState, + BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState, }; use editor_runtime::command_sync::{ read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command, @@ -5416,8 +5420,6 @@ fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlock let block_left = block_rect.left() - stage_rect.left(); let block_top = block_rect.top() - stage_rect.top(); let block_bottom = block_rect.bottom() - stage_rect.top(); - let x = f64::from(client_x) - stage_rect.left(); - let y = f64::from(client_y) - stage_rect.top(); let handle_left = handle_rect .as_ref() .map(|rect| rect.left() - stage_rect.left()) @@ -5426,10 +5428,20 @@ fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlock .as_ref() .map(|rect| rect.right() - stage_rect.left()) .unwrap_or_else(|| handle_left + HANDLE_TRIGGER_WIDTH); - let corridor_left = (handle_left - 12.0).min(block_left); - let corridor_right = (block_left + 18.0).max(handle_right + 12.0); - x >= corridor_left && x <= corridor_right && y >= block_top - 18.0 && y <= block_bottom + 18.0 + pointer_in_handle_corridor_geometry( + client_x, + client_y, + &HandleCorridorGeometry { + stage_left: stage_rect.left(), + stage_top: stage_rect.top(), + block_left, + block_top, + block_bottom, + handle_left, + handle_right, + }, + ) } fn drop_indicator_from_target( @@ -5442,16 +5454,8 @@ fn drop_indicator_from_target( let stage = editor_stage_element()?; let block_rect = block.get_bounding_client_rect(); let stage_rect = stage.get_bounding_client_rect(); - let midpoint = block_rect.top() + (block_rect.height() / 2.0); - let placement = if f64::from(client_y) <= midpoint { - DropPlacement::Before - } else { - DropPlacement::After - }; - let top = match placement { - DropPlacement::Before => block_rect.top() - stage_rect.top(), - DropPlacement::After => block_rect.bottom() - stage_rect.top(), - }; + let placement = drop_placement_from_block_point(client_y, block_rect.top(), block_rect.height()); + let top = drop_indicator_top(stage_rect.top(), block_rect.top(), block_rect.bottom(), placement); Some(DropIndicatorState { index: hovered.index,