diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index cc1fabfd..c5459a51 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -207,7 +207,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib ssr::pages::layout - [x] D1. `tree-shell-state-runtime.js`:state hydration、serialization、patch dispatch。 - [x] D2. `tree-shell-page-runtime.js`:page tree keyboard、expand、focus、drag/drop intent。 - [x] D3. `tree-shell-filetree-runtime.js`:filetree row normalization、resource meta、open target。 -- [ ] D4. `tree-shell-picker-runtime.js`:picker search/focus/pick root。 +- [x] D4. `tree-shell-picker-runtime.js`:picker search/focus/pick root。 - [ ] D5. `tree-shell-dom-runtime.js`:DOM patch/render helpers。 - [x] D6. `tree-shell-icons-runtime.js`:icon templates 和 resource kind badge。 - [ ] D7. entrypoint 少于 2,500 行。 diff --git a/rust/crates/mnote-web/browser/tree-shell-picker-runtime.js b/rust/crates/mnote-web/browser/tree-shell-picker-runtime.js new file mode 100644 index 00000000..bfcc364c --- /dev/null +++ b/rust/crates/mnote-web/browser/tree-shell-picker-runtime.js @@ -0,0 +1,147 @@ +// MNote debug /tree shell picker focus and selection reducers. + +import { normalizeTreeShellText } from "./tree-shell-state-runtime.js"; + +export function getTreeShellVisiblePickerEntries(context) { + if (context.mode !== "picker") { + return []; + } + + const visible = []; + if (context.allowRootPick) { + visible.push({ + pickerItemKey: "__root__", + item: null, + }); + } + + const walk = (entries) => { + entries.forEach((item) => { + visible.push({ + pickerItemKey: item.nodeId, + item, + }); + if (item.childCount > 0 && context.expanded.has(item.nodeId)) { + walk(context.getSiblings(item.nodeId)); + } + }); + }; + + walk(context.roots); + return visible; +} + +export function isTreeShellPickerEntryPickable(entry, context) { + if (!entry) return false; + if (entry.pickerItemKey === "__root__") { + return context.allowRootPick; + } + const documentId = normalizeTreeShellText(entry.item?.nodeId || entry.pickerItemKey); + return Boolean(documentId && !context.excludedIds.has(documentId)); +} + +export function normalizeTreeShellPickerItemKey(pickerItemKey, context) { + const normalizedItemKey = normalizeTreeShellText(pickerItemKey); + if (normalizedItemKey === "__root__" && context.allowRootPick) { + return "__root__"; + } + if ( + normalizedItemKey && + context.itemById.has(normalizedItemKey) && + !context.excludedIds.has(normalizedItemKey) + ) { + return normalizedItemKey; + } + return ""; +} + +export function resolveTreeShellCurrentPickerItemKey(context) { + const fromActive = context.normalizePickerItemKey(context.currentActivePickerItemKey); + if (fromActive) return fromActive; + const fromDocument = context.normalizePickerItemKey(context.currentActiveDocumentId); + if (fromDocument) return fromDocument; + return context.getPickablePickerEntries()[0]?.pickerItemKey || ""; +} + +export function computeTreeShellPickerStateActionResult(context, action) { + const currentPickerItemKey = context.resolveCurrentPickerItemKey(); + if ( + context.mode !== "picker" || + context.pickerStateReducerContractName !== "rust_picker_state_reducer_v1" || + !context.pickerStateReducerActions.has(action?.kind || "") + ) { + return { + nextItemKey: currentPickerItemKey, + pickedDocumentId: + action?.kind === "pick" && currentPickerItemKey !== "__root__" + ? currentPickerItemKey || null + : null, + pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__", + }; + } + + const pickable = context.getPickablePickerEntries(); + if (pickable.length === 0) { + return { + nextItemKey: "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + const currentIndex = pickable.findIndex( + (entry) => entry.pickerItemKey === currentPickerItemKey, + ); + const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; + const actionKind = action.kind; + + if (actionKind === "normalize") { + return { + nextItemKey: pickable[resolvedIndex]?.pickerItemKey || "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + + if (actionKind === "focus") { + const nextItemKey = context.normalizePickerItemKey(action.itemKey); + return { + nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "", + pickedDocumentId: null, + pickedRoot: false, + }; + } + + if (actionKind === "pick") { + const target = pickable[resolvedIndex]; + const targetKey = target?.pickerItemKey || ""; + return { + nextItemKey: targetKey, + pickedDocumentId: + targetKey && targetKey !== "__root__" ? targetKey : null, + pickedRoot: targetKey === "__root__", + }; + } + + let nextIndex = resolvedIndex; + if (actionKind === "next") { + nextIndex = Math.min(pickable.length - 1, resolvedIndex + 1); + } else if (actionKind === "previous") { + nextIndex = Math.max(0, resolvedIndex - 1); + } else if (actionKind === "home") { + nextIndex = 0; + } else if (actionKind === "end") { + nextIndex = pickable.length - 1; + } else { + return { + nextItemKey: currentPickerItemKey, + pickedDocumentId: null, + pickedRoot: false, + }; + } + + return { + nextItemKey: pickable[nextIndex]?.pickerItemKey || "", + pickedDocumentId: null, + pickedRoot: false, + }; +} diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js index 0a5c9e9b..539d24d8 100644 --- a/rust/crates/mnote-web/browser/tree-shell-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js @@ -20,6 +20,13 @@ import { getFileTreeRowOwnerDocumentId, normalizeTreeShellTreeItems, } from "./tree-shell-filetree-runtime.js"; +import { + computeTreeShellPickerStateActionResult, + getTreeShellVisiblePickerEntries, + isTreeShellPickerEntryPickable, + normalizeTreeShellPickerItemKey, + resolveTreeShellCurrentPickerItemKey, +} from "./tree-shell-picker-runtime.js"; import { normalizeTreeShellNumber as normalizeNumber, normalizeTreeShellStringArray as normalizeStringArray, @@ -1767,148 +1774,48 @@ function startTreeShellRuntime() { return visible; }; - const getVisiblePickerEntries = () => { - if (mode !== "picker") { - return []; - } + const getVisiblePickerEntries = () => + getTreeShellVisiblePickerEntries({ + mode, + allowRootPick, + roots, + expanded, + getSiblings, + }); - const visible = []; - if (allowRootPick) { - visible.push({ - pickerItemKey: "__root__", - item: null, - }); - } - - const walk = (entries) => { - entries.forEach((item) => { - visible.push({ - pickerItemKey: item.nodeId, - item, - }); - if (item.childCount > 0 && expanded.has(item.nodeId)) { - walk(getSiblings(item.nodeId)); - } - }); - }; - - walk(roots); - return visible; - }; - - const isPickerEntryPickable = (entry) => { - if (!entry) return false; - if (entry.pickerItemKey === "__root__") { - return allowRootPick; - } - const documentId = normalizeText(entry.item?.nodeId || entry.pickerItemKey); - return Boolean(documentId && !excludedIds.has(documentId)); - }; + const isPickerEntryPickable = (entry) => + isTreeShellPickerEntryPickable(entry, { + allowRootPick, + excludedIds, + }); const getPickablePickerEntries = () => getVisiblePickerEntries().filter((entry) => isPickerEntryPickable(entry)); - const normalizePickerItemKey = (pickerItemKey) => { - const normalizedItemKey = normalizeText(pickerItemKey); - if (normalizedItemKey === "__root__" && allowRootPick) { - return "__root__"; - } - if (normalizedItemKey && itemById.has(normalizedItemKey) && !excludedIds.has(normalizedItemKey)) { - return normalizedItemKey; - } - return ""; - }; + const normalizePickerItemKey = (pickerItemKey) => + normalizeTreeShellPickerItemKey(pickerItemKey, { + allowRootPick, + itemById, + excludedIds, + }); - const resolveCurrentPickerItemKey = () => { - const fromActive = normalizePickerItemKey(currentActivePickerItemKey); - if (fromActive) return fromActive; - const fromDocument = normalizePickerItemKey(currentActiveDocumentId); - if (fromDocument) return fromDocument; - return getPickablePickerEntries()[0]?.pickerItemKey || ""; - }; + const resolveCurrentPickerItemKey = () => + resolveTreeShellCurrentPickerItemKey({ + currentActivePickerItemKey, + currentActiveDocumentId, + normalizePickerItemKey, + getPickablePickerEntries, + }); - const computePickerStateActionResult = (action) => { - const currentPickerItemKey = resolveCurrentPickerItemKey(); - if ( - mode !== "picker" || - pickerStateReducerContractName !== "rust_picker_state_reducer_v1" || - !pickerStateReducerActions.has(action?.kind || "") - ) { - return { - nextItemKey: currentPickerItemKey, - pickedDocumentId: - action?.kind === "pick" && currentPickerItemKey !== "__root__" - ? currentPickerItemKey || null - : null, - pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__", - }; - } - - const pickable = getPickablePickerEntries(); - if (pickable.length === 0) { - return { - nextItemKey: "", - pickedDocumentId: null, - pickedRoot: false, - }; - } - const currentIndex = pickable.findIndex( - (entry) => entry.pickerItemKey === currentPickerItemKey, - ); - const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; - const actionKind = action.kind; - - if (actionKind === "normalize") { - return { - nextItemKey: pickable[resolvedIndex]?.pickerItemKey || "", - pickedDocumentId: null, - pickedRoot: false, - }; - } - - if (actionKind === "focus") { - const nextItemKey = normalizePickerItemKey(action.itemKey); - return { - nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "", - pickedDocumentId: null, - pickedRoot: false, - }; - } - - if (actionKind === "pick") { - const target = pickable[resolvedIndex]; - const targetKey = target?.pickerItemKey || ""; - return { - nextItemKey: targetKey, - pickedDocumentId: - targetKey && targetKey !== "__root__" ? targetKey : null, - pickedRoot: targetKey === "__root__", - }; - } - - let nextIndex = resolvedIndex; - if (actionKind === "next") { - nextIndex = Math.min(pickable.length - 1, resolvedIndex + 1); - } else if (actionKind === "previous") { - nextIndex = Math.max(0, resolvedIndex - 1); - } else if (actionKind === "home") { - nextIndex = 0; - } else if (actionKind === "end") { - nextIndex = pickable.length - 1; - } else { - return { - nextItemKey: currentPickerItemKey, - pickedDocumentId: null, - pickedRoot: false, - }; - } - - return { - nextItemKey: pickable[nextIndex]?.pickerItemKey || "", - pickedDocumentId: null, - pickedRoot: false, - }; - }; + const computePickerStateActionResult = (action) => + computeTreeShellPickerStateActionResult({ + mode, + pickerStateReducerContractName, + pickerStateReducerActions, + resolveCurrentPickerItemKey, + getPickablePickerEntries, + normalizePickerItemKey, + }, action); const focusNode = (nodeId) => { if (!nodeId || !itemById.has(nodeId)) return; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index a31d674d..cdf2e9b3 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -182,6 +182,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js", get(web_shell::tree_shell_filetree_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", + get(web_shell::tree_shell_picker_runtime_asset), + ) .route( "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", get(web_shell::document_conflict_panel_runtime_asset), @@ -637,6 +641,7 @@ mod tests { "/api/mnote-browser-runtime/tree-shell-state-runtime.js", "/api/mnote-browser-runtime/tree-shell-icons-runtime.js", "/api/mnote-browser-runtime/tree-shell-filetree-runtime.js", + "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", "/api/mnote-browser-runtime/document-pane-runtime.js", "/api/mnote-browser-runtime/document-mindmap-host-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 0d70ec46..95976076 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -994,6 +994,20 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn tree_shell_picker_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn document_conflict_panel_runtime_asset() -> Response { // include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/) const JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js");