refactor: extract block menu overlay layout

This commit is contained in:
lix-2026
2026-05-25 02:50:31 +08:00
parent 630a7870e7
commit 3b8445ffab
6 changed files with 114 additions and 55 deletions
@@ -123,3 +123,4 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 未完成:`acceptDiskVersion``keepCurrentEditorVersion``writeMergedConflictResult`、document session lifecycle、secondary pane conflict smoke 仍未迁出或覆盖。
- 2026-05-25Batch 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-25Batch F 计划继续采用独立 worktree 候选 patchWorker B 负责把 file tree 上传目标解析的纯 fallback / plan 归一化 helper 外置到 `filetree-runtime.js``layout.rs` 仍保留 async preflight 和完整 inline fallback,避免和当前 conflict panel 写集冲突。
- 2026-05-25Batch 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 无 difftranscript 停在 `ask_choice`,原因是 worker 误判任务书拼接了 Worker A / Worker B 两个任务并要求人工选择。Codex 已终止 runner / ACP,不采纳该 run。
@@ -115,3 +115,8 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 已验证:`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`
- 未完成:`sync_editor_overlay_state` / `try_sync_editor_overlay_state`、block menu signal 和拖拽重排行为仍在 `lib.rs`,后续应继续按更窄 helper 拆分。
- 2026-05-25Batch F 计划继续采用独立 worktree 候选 patchWorker A 负责把 `hover_anchor_top``hover_anchor_transform``block_menu_layout` 或其几何计算部分迁入 `editor_runtime/block_menu_overlay.rs`,不迁拖拽 hit-test、signal 编排或 block menu 行为。
- 2026-05-25:采纳并复核 Batch F Worker A 候选 patch:新增 `editor_runtime/block_menu_overlay.rs`,迁出 `hover_anchor_top``hover_anchor_transform``block_menu_layout` 的纯几何计算;`lib.rs` 仍保留 `editor_stage_element` DOM 查询、拖拽 hit-test、signal 编排和 block menu 行为。
- Reasonix run`reasonix-2026-05-24T18-39-34-366Z-631ed269`,已读取 Hindsight recall、`process-handoff.md/json``result.json`、diff 与验证摘要;Reasonix 自报 smoke 挂起,Codex 不采信该部分。
- 修改:`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`
@@ -0,0 +1,100 @@
//! Block menu hover anchor / layout helper。
//!
//! 本模块承接 block menu 的锚点与面板布局计算,保持几何计算与 DOM 查询分离。
//! 调用方(lib.rs)保留 DOM 查询(editor_stage_element),本模块只做纯几何计算。
//!
//! 迁入自 lib.rs
//! - `hover_anchor_top`
//! - `hover_anchor_transform`
//! - `block_menu_layout`(参数化版)
use crate::editor_runtime::block_hover_state::{BlockMenuLayout, HoveredBlockState};
/// 多行块的最小高度阈值,超过此值视作多行。
const HANDLE_MULTILINE_HEIGHT: f64 = 40.0;
/// handle shell 菜单左侧最小限制。
const HANDLE_MENU_LEFT_LIMIT: f64 = -320.0;
/// 内容列水平内边距(与 lib.rs 中同名常量同值)。
const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0;
/// 内容列最大宽度(与 lib.rs 中同名常量同值)。
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
/// 根据 stage 宽度计算实际内容列宽。
fn content_column_width(stage_rect_width: f64) -> f64 {
let available = (stage_rect_width - (CONTENT_COLUMN_HORIZONTAL_PADDING * 2.0)).max(0.0);
available.min(CONTENT_COLUMN_MAX_WIDTH)
}
/// 内容列左侧相对于 stage 的偏移。
fn content_column_left(stage_rect_width: f64) -> f64 {
let column_width = content_column_width(stage_rect_width);
((stage_rect_width - column_width) / 2.0).max(0.0)
}
/// 内容文本区左侧相对于 stage 的偏移(列左内边距后)。
fn content_text_left(stage_rect_width: f64) -> f64 {
content_column_left(stage_rect_width) + CONTENT_COLUMN_HORIZONTAL_PADDING
}
/// 计算 handle shell 锚点在 stage 内的垂直位置。
pub(crate) fn hover_anchor_top(block: &HoveredBlockState) -> f64 {
if block.height > HANDLE_MULTILINE_HEIGHT {
block.top + 12.0
} else {
block.top + (block.height / 2.0) - 32.0
}
}
/// 计算 handle shell 锚点 transform 值(当前固定返回 "none")。
pub(crate) fn hover_anchor_transform(_block: &HoveredBlockState) -> &'static str {
"none"
}
/// 计算 block menu 面板布局。
///
/// 参数化版本:调用方提供 stage 的 bounding client rect 尺寸和位置,
/// 本函数做纯几何计算,不查询 DOM。
pub(crate) fn block_menu_layout(
block: &HoveredBlockState,
stage_width: f64,
stage_height: f64,
stage_top: f64,
stage_left: f64,
) -> Option<BlockMenuLayout> {
let shell_top = hover_anchor_top(block);
let stage_padding = 18.0;
let space_above = (shell_top - stage_padding).max(0.0);
let space_below = (stage_height - shell_top - stage_padding).max(0.0);
let open_upward = space_below < 340.0 && space_above > space_below;
let available_height = if open_upward {
space_above
} else {
space_below
};
let max_height = if available_height >= 220.0 {
available_height
} else {
available_height.max(160.0)
};
let menu_width = 242.0;
let menu_stage_left =
(content_text_left(stage_width) - menu_width - 132.0).max(HANDLE_MENU_LEFT_LIMIT);
let viewport_shell_top = stage_top + shell_top;
let top = if open_upward {
viewport_shell_top + 64.0 - max_height
} else {
viewport_shell_top
}
.max(8.0);
let left = (stage_left + menu_stage_left).max(8.0);
Some(BlockMenuLayout {
max_height,
open_upward,
top,
left,
})
}
@@ -2,6 +2,7 @@ pub(crate) mod attachment_links;
pub(crate) mod attachment_upload;
pub(crate) mod block_hover_state;
pub(crate) mod block_menu_document;
pub(crate) mod block_menu_overlay;
pub(crate) mod block_menu_legacy_html;
pub(crate) mod command_sync;
pub(crate) mod dom_selection;
+7 -55
View File
@@ -21,6 +21,7 @@ use web_sys::{
};
use editor_runtime::attachment_upload::dispatch_editor_upload_request;
use editor_runtime::block_menu_overlay;
use editor_runtime::block_menu_legacy_html::{
duplicate_top_level_block_html, reorder_top_level_block_html,
};
@@ -47,7 +48,6 @@ use editor_runtime::persistence::persisted_document_storage_key;
const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
const HANDLE_MULTILINE_HEIGHT: f64 = 40.0;
const CONTENT_COLUMN_MAX_WIDTH: f64 = 708.0;
const CONTENT_COLUMN_HORIZONTAL_PADDING: f64 = 48.0;
const HANDLE_STAGE_PADDING_LEFT: f64 = 18.0;
@@ -55,7 +55,6 @@ const HANDLE_TRIGGER_WIDTH: f64 = 22.0;
const HANDLE_TRIGGER_GAP: f64 = 4.0;
const HANDLE_MENU_GAP: f64 = 0.0;
const HANDLE_TEXT_ALIGN_OFFSET: f64 = 96.0;
const HANDLE_MENU_LEFT_LIMIT: f64 = -320.0;
const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime";
const RUNTIME_VERSION: &str = "1.1.0";
const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1";
@@ -5391,18 +5390,6 @@ fn image_element_from_target(target: web_sys::EventTarget) -> Option<Element> {
.flatten()
}
fn hover_anchor_top(block: &HoveredBlockState) -> f64 {
if block.height > HANDLE_MULTILINE_HEIGHT {
block.top + 12.0
} else {
block.top + (block.height / 2.0) - 32.0
}
}
fn hover_anchor_transform(_block: &HoveredBlockState) -> &'static str {
"none"
}
fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlockState) -> bool {
let Some(stage) = editor_stage_element() else {
return false;
@@ -5445,44 +5432,6 @@ fn pointer_in_handle_corridor(client_x: i32, client_y: i32, block: &HoveredBlock
x >= corridor_left && x <= corridor_right && y >= block_top - 18.0 && y <= block_bottom + 18.0
}
fn block_menu_layout(block: &HoveredBlockState) -> Option<BlockMenuLayout> {
let stage = editor_stage_element()?;
let stage_rect = stage.get_bounding_client_rect();
let shell_top = hover_anchor_top(block);
let stage_padding = 18.0;
let space_above = (shell_top - stage_padding).max(0.0);
let space_below = (stage_rect.height() - shell_top - stage_padding).max(0.0);
let open_upward = space_below < 340.0 && space_above > space_below;
let available_height = if open_upward {
space_above
} else {
space_below
};
let max_height = if available_height >= 220.0 {
available_height
} else {
available_height.max(160.0)
};
let menu_width = 242.0;
let menu_stage_left =
(content_text_left(stage_rect.width()) - menu_width - 132.0).max(HANDLE_MENU_LEFT_LIMIT);
let viewport_shell_top = stage_rect.top() + shell_top;
let top = if open_upward {
viewport_shell_top + 64.0 - max_height
} else {
viewport_shell_top
}
.max(8.0);
let left = (stage_rect.left() + menu_stage_left).max(8.0);
Some(BlockMenuLayout {
max_height,
open_upward,
top,
left,
})
}
fn drop_indicator_from_target(
target: web_sys::EventTarget,
client_y: i32,
@@ -9714,8 +9663,8 @@ fn App(mount_options: MountOptions) -> impl IntoView {
return ().into_any();
}
let anchor_top = format!("{}px", hover_anchor_top(&block));
let anchor_transform = hover_anchor_transform(&block);
let anchor_top = format!("{}px", block_menu_overlay::hover_anchor_top(&block));
let anchor_transform = block_menu_overlay::hover_anchor_transform(&block);
let handle_left = editor_stage_element()
.map(|stage| {
let stage_rect = stage.get_bounding_client_rect();
@@ -9905,7 +9854,10 @@ fn App(mount_options: MountOptions) -> impl IntoView {
{
let current_duplicate_label = duplicate_block_label.clone();
let current_delete_label = delete_block_label.clone();
let menu_layout = block_menu_layout(&block).unwrap_or(BlockMenuLayout {
let menu_layout = editor_stage_element().and_then(|stage| {
let rect = stage.get_bounding_client_rect();
block_menu_overlay::block_menu_layout(&block, rect.width(), rect.height(), rect.top(), rect.left())
}).unwrap_or(BlockMenuLayout {
max_height: 360.0,
open_upward: false,
top: 80.0,