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 c5459a51..f8cf002d 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 @@ -208,7 +208,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib ssr::pages::layout - [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。 - [x] D4. `tree-shell-picker-runtime.js`:picker search/focus/pick root。 -- [ ] D5. `tree-shell-dom-runtime.js`:DOM patch/render helpers。 +- [x] 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-dom-runtime.js b/rust/crates/mnote-web/browser/tree-shell-dom-runtime.js new file mode 100644 index 00000000..0e3bc0e8 --- /dev/null +++ b/rust/crates/mnote-web/browser/tree-shell-dom-runtime.js @@ -0,0 +1,137 @@ +// MNote debug /tree shell DOM patch and hydration helpers. + +export function patchTreeShellPageActiveDom(context) { + if (context.mode !== "page") return; + context.appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = context.normalizeText(row.dataset.nodeId); + const isFocused = nodeId === context.focusedNodeId; + row.dataset.active = String(nodeId === context.currentActiveDocumentId); + row.dataset.focused = String(isFocused); + row.tabIndex = isFocused ? 0 : -1; + row.dataset.dropFeedback = String(context.activePageDropNodeId === nodeId); + }); +} + +export function patchTreeShellPageExpansionDom(context, nodeId) { + if (context.mode !== "page") return false; + const normalizedNodeId = context.normalizeText(nodeId); + if (!normalizedNodeId) return false; + const item = context.itemById.get(normalizedNodeId); + if (!item) return false; + const nodeElement = context.appElement.querySelector( + `.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`, + ); + if (!(nodeElement instanceof HTMLElement)) return false; + const row = nodeElement.querySelector( + `:scope > .tree-row[data-node-id="${CSS.escape(normalizedNodeId)}"]`, + ); + const children = context.getSiblings(normalizedNodeId); + const hasChildren = item.childCount > 0 && children.length > 0; + const isExpanded = hasChildren && context.expanded.has(normalizedNodeId); + if (row instanceof HTMLElement) { + row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false"); + const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]'); + if (toggleButton instanceof HTMLButtonElement) { + toggleButton.textContent = isExpanded ? "▾" : "▸"; + toggleButton.setAttribute( + "aria-label", + `${isExpanded ? "折叠" : "展开"} ${item.title}`, + ); + } + } + if (!hasChildren) { + context.patchPageTreeActiveDom(); + return true; + } + let childrenList = Array.from(nodeElement.children).find( + (child) => child instanceof HTMLElement && child.classList.contains("tree-children"), + ); + if (isExpanded) { + if (!(childrenList instanceof HTMLElement)) { + childrenList = document.createElement("ul"); + childrenList.className = "tree-children"; + children.forEach((child) => { + childrenList.appendChild(context.renderNode(child)); + }); + nodeElement.appendChild(childrenList); + } + childrenList.hidden = false; + childrenList.style.display = ""; + } else if (childrenList instanceof HTMLElement) { + childrenList.hidden = true; + childrenList.style.display = "none"; + } + context.patchPageTreeActiveDom(); + return true; +} + +export function hydrateTreeShellInitialPageTree(context) { + if (context.mode !== "page") return false; + const root = context.appElement.querySelector('[data-rust-page-renderer="initial_v1"]'); + if (!(root instanceof HTMLElement)) { + return false; + } + root.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = context.normalizeText(row.dataset.nodeId); + const item = context.itemById.get(nodeId); + if (!item) return; + context.bindPageRowEvents(row, item); + }); + if (context.focusedNodeId) { + context.focusRowElement(context.focusedNodeId); + } + return true; +} + +export function focusTreeShellPickerRowElement(context, pickerItemKey) { + const normalizedItemKey = context.normalizeText(pickerItemKey); + window.requestAnimationFrame(() => { + const row = + normalizedItemKey === "__root__" + ? context.appElement.querySelector('[data-rust-rendered-row="picker-root"]') + : context.appElement.querySelector( + `.tree-row[data-node-id="${CSS.escape(normalizedItemKey)}"]`, + ); + if (!(row instanceof HTMLElement)) return; + row.focus({ preventScroll: true }); + row.scrollIntoView({ block: "nearest" }); + }); +} + +export function patchTreeShellPickerActiveDom(context) { + if (context.mode !== "picker") return; + context.appElement + .querySelectorAll('[data-rust-rendered-row="picker"], [data-rust-rendered-row="picker-root"]') + .forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = context.normalizeText(row.dataset.nodeId); + const isRoot = row.dataset.rustRenderedRow === "picker-root"; + const isFocused = isRoot + ? context.currentActivePickerItemKey === "__root__" + : context.currentActivePickerItemKey === nodeId || + (!context.currentActivePickerItemKey && context.currentActiveDocumentId === nodeId); + row.dataset.focused = String(isFocused); + row.tabIndex = isFocused ? 0 : -1; + }); +} + +export function hydrateTreeShellInitialPickerTree(context) { + if (context.mode !== "picker") return false; + const root = context.appElement.querySelector('[data-rust-picker-renderer="initial_v1"]'); + if (!(root instanceof HTMLElement)) { + return false; + } + root.querySelectorAll('[data-rust-rendered-row="picker-root"]').forEach((row) => { + context.bindPickerRootEvents(row); + }); + root.querySelectorAll('[data-rust-rendered-row="picker"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = context.normalizeText(row.dataset.nodeId); + const item = context.itemById.get(nodeId); + if (!item) return; + context.bindPickerRowEvents(row, item); + }); + return true; +} diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js index 539d24d8..741fa4e1 100644 --- a/rust/crates/mnote-web/browser/tree-shell-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js @@ -20,6 +20,14 @@ import { getFileTreeRowOwnerDocumentId, normalizeTreeShellTreeItems, } from "./tree-shell-filetree-runtime.js"; +import { + focusTreeShellPickerRowElement, + hydrateTreeShellInitialPageTree, + hydrateTreeShellInitialPickerTree, + patchTreeShellPageActiveDom, + patchTreeShellPageExpansionDom, + patchTreeShellPickerActiveDom, +} from "./tree-shell-dom-runtime.js"; import { computeTreeShellPickerStateActionResult, getTreeShellVisiblePickerEntries, @@ -2943,90 +2951,38 @@ function startTreeShellRuntime() { }); }; - const patchPageTreeActiveDom = () => { - if (mode !== "page") return; - appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { - if (!(row instanceof HTMLElement)) return; - const nodeId = normalizeText(row.dataset.nodeId); - const isFocused = nodeId === focusedNodeId; - row.dataset.active = String(nodeId === currentActiveDocumentId); - row.dataset.focused = String(isFocused); - row.tabIndex = isFocused ? 0 : -1; - row.dataset.dropFeedback = String(activePageDropNodeId === nodeId); + const patchPageTreeActiveDom = () => + patchTreeShellPageActiveDom({ + mode, + appElement, + normalizeText, + focusedNodeId, + currentActiveDocumentId, + activePageDropNodeId, }); - }; - const patchPageTreeExpansionDom = (nodeId) => { - if (mode !== "page") return false; - const normalizedNodeId = normalizeText(nodeId); - if (!normalizedNodeId) return false; - const item = itemById.get(normalizedNodeId); - if (!item) return false; - const nodeElement = appElement.querySelector( - `.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`, - ); - if (!(nodeElement instanceof HTMLElement)) return false; - const row = nodeElement.querySelector( - `:scope > .tree-row[data-node-id="${CSS.escape(normalizedNodeId)}"]`, - ); - const children = getSiblings(normalizedNodeId); - const hasChildren = item.childCount > 0 && children.length > 0; - const isExpanded = hasChildren && expanded.has(normalizedNodeId); - if (row instanceof HTMLElement) { - row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false"); - const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]'); - if (toggleButton instanceof HTMLButtonElement) { - toggleButton.textContent = isExpanded ? "▾" : "▸"; - toggleButton.setAttribute( - "aria-label", - `${isExpanded ? "折叠" : "展开"} ${item.title}`, - ); - } - } - if (!hasChildren) { - patchPageTreeActiveDom(); - return true; - } - let childrenList = Array.from(nodeElement.children).find( - (child) => child instanceof HTMLElement && child.classList.contains("tree-children"), - ); - if (isExpanded) { - if (!(childrenList instanceof HTMLElement)) { - childrenList = document.createElement("ul"); - childrenList.className = "tree-children"; - children.forEach((child) => { - childrenList.appendChild(renderNode(child)); - }); - nodeElement.appendChild(childrenList); - } - childrenList.hidden = false; - childrenList.style.display = ""; - } else if (childrenList instanceof HTMLElement) { - childrenList.hidden = true; - childrenList.style.display = "none"; - } - patchPageTreeActiveDom(); - return true; - }; + const patchPageTreeExpansionDom = (nodeId) => + patchTreeShellPageExpansionDom({ + mode, + appElement, + normalizeText, + itemById, + expanded, + getSiblings, + renderNode, + patchPageTreeActiveDom, + }, nodeId); - const hydrateInitialPageTree = () => { - if (mode !== "page") return false; - const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]'); - if (!(root instanceof HTMLElement)) { - return false; - } - root.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { - if (!(row instanceof HTMLElement)) return; - const nodeId = normalizeText(row.dataset.nodeId); - const item = itemById.get(nodeId); - if (!item) return; - bindPageRowEvents(row, item); + const hydrateInitialPageTree = () => + hydrateTreeShellInitialPageTree({ + mode, + appElement, + normalizeText, + itemById, + bindPageRowEvents, + focusedNodeId, + focusRowElement, }); - if (focusedNodeId) { - focusRowElement(focusedNodeId); - } - return true; - }; const syncFileTreeSelectionDom = () => { if (mode !== "filetree") return; @@ -3333,56 +3289,30 @@ function startTreeShellRuntime() { }); }; - const focusPickerRowElement = (pickerItemKey) => { - const normalizedItemKey = normalizeText(pickerItemKey); - window.requestAnimationFrame(() => { - const row = - normalizedItemKey === "__root__" - ? appElement.querySelector('[data-rust-rendered-row="picker-root"]') - : appElement.querySelector( - `.tree-row[data-node-id="${CSS.escape(normalizedItemKey)}"]`, - ); - if (!(row instanceof HTMLElement)) return; - row.focus({ preventScroll: true }); - row.scrollIntoView({ block: "nearest" }); - }); - }; + const focusPickerRowElement = (pickerItemKey) => + focusTreeShellPickerRowElement({ + appElement, + normalizeText, + }, pickerItemKey); - const patchPickerActiveDom = () => { - if (mode !== "picker") return; - appElement - .querySelectorAll('[data-rust-rendered-row="picker"], [data-rust-rendered-row="picker-root"]') - .forEach((row) => { - if (!(row instanceof HTMLElement)) return; - const nodeId = normalizeText(row.dataset.nodeId); - const isRoot = row.dataset.rustRenderedRow === "picker-root"; - const isFocused = isRoot - ? currentActivePickerItemKey === "__root__" - : currentActivePickerItemKey === nodeId || - (!currentActivePickerItemKey && currentActiveDocumentId === nodeId); - row.dataset.focused = String(isFocused); - row.tabIndex = isFocused ? 0 : -1; - }); - }; + const patchPickerActiveDom = () => + patchTreeShellPickerActiveDom({ + mode, + appElement, + normalizeText, + currentActivePickerItemKey, + currentActiveDocumentId, + }); - const hydrateInitialPickerTree = () => { - if (mode !== "picker") return false; - const root = appElement.querySelector('[data-rust-picker-renderer="initial_v1"]'); - if (!(root instanceof HTMLElement)) { - return false; - } - root.querySelectorAll('[data-rust-rendered-row="picker-root"]').forEach((row) => { - bindPickerRootEvents(row); + const hydrateInitialPickerTree = () => + hydrateTreeShellInitialPickerTree({ + mode, + appElement, + normalizeText, + itemById, + bindPickerRootEvents, + bindPickerRowEvents, }); - root.querySelectorAll('[data-rust-rendered-row="picker"]').forEach((row) => { - if (!(row instanceof HTMLElement)) return; - const nodeId = normalizeText(row.dataset.nodeId); - const item = itemById.get(nodeId); - if (!item) return; - bindPickerRowEvents(row, item); - }); - return true; - }; const hydrateInitialRenderer = () => { if (mode === "page") { diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index cdf2e9b3..b761bd8c 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -186,6 +186,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/tree-shell-picker-runtime.js", get(web_shell::tree_shell_picker_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/tree-shell-dom-runtime.js", + get(web_shell::tree_shell_dom_runtime_asset), + ) .route( "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", get(web_shell::document_conflict_panel_runtime_asset), @@ -642,6 +646,7 @@ mod tests { "/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/tree-shell-dom-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 95976076..8211bd2d 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1008,6 +1008,20 @@ pub async fn tree_shell_picker_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn tree_shell_dom_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/tree-shell-dom-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");