refactor: split tree shell dom runtime

This commit is contained in:
lix-2026
2026-05-26 03:02:15 +08:00
parent 40c51c5cc6
commit fb1575b4eb
5 changed files with 214 additions and 128 deletions
@@ -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] 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] 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。 - [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。 - [x] D6. `tree-shell-icons-runtime.js`icon templates 和 resource kind badge。
- [ ] D7. entrypoint 少于 2,500 行。 - [ ] D7. entrypoint 少于 2,500 行。
@@ -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;
}
@@ -20,6 +20,14 @@ import {
getFileTreeRowOwnerDocumentId, getFileTreeRowOwnerDocumentId,
normalizeTreeShellTreeItems, normalizeTreeShellTreeItems,
} from "./tree-shell-filetree-runtime.js"; } from "./tree-shell-filetree-runtime.js";
import {
focusTreeShellPickerRowElement,
hydrateTreeShellInitialPageTree,
hydrateTreeShellInitialPickerTree,
patchTreeShellPageActiveDom,
patchTreeShellPageExpansionDom,
patchTreeShellPickerActiveDom,
} from "./tree-shell-dom-runtime.js";
import { import {
computeTreeShellPickerStateActionResult, computeTreeShellPickerStateActionResult,
getTreeShellVisiblePickerEntries, getTreeShellVisiblePickerEntries,
@@ -2943,90 +2951,38 @@ function startTreeShellRuntime() {
}); });
}; };
const patchPageTreeActiveDom = () => { const patchPageTreeActiveDom = () =>
if (mode !== "page") return; patchTreeShellPageActiveDom({
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { mode,
if (!(row instanceof HTMLElement)) return; appElement,
const nodeId = normalizeText(row.dataset.nodeId); normalizeText,
const isFocused = nodeId === focusedNodeId; focusedNodeId,
row.dataset.active = String(nodeId === currentActiveDocumentId); currentActiveDocumentId,
row.dataset.focused = String(isFocused); activePageDropNodeId,
row.tabIndex = isFocused ? 0 : -1;
row.dataset.dropFeedback = String(activePageDropNodeId === nodeId);
}); });
};
const patchPageTreeExpansionDom = (nodeId) => { const patchPageTreeExpansionDom = (nodeId) =>
if (mode !== "page") return false; patchTreeShellPageExpansionDom({
const normalizedNodeId = normalizeText(nodeId); mode,
if (!normalizedNodeId) return false; appElement,
const item = itemById.get(normalizedNodeId); normalizeText,
if (!item) return false; itemById,
const nodeElement = appElement.querySelector( expanded,
`.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`, getSiblings,
); renderNode,
if (!(nodeElement instanceof HTMLElement)) return false; patchPageTreeActiveDom,
const row = nodeElement.querySelector( }, nodeId);
`: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 hydrateInitialPageTree = () => { const hydrateInitialPageTree = () =>
if (mode !== "page") return false; hydrateTreeShellInitialPageTree({
const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]'); mode,
if (!(root instanceof HTMLElement)) { appElement,
return false; normalizeText,
} itemById,
root.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { bindPageRowEvents,
if (!(row instanceof HTMLElement)) return; focusedNodeId,
const nodeId = normalizeText(row.dataset.nodeId); focusRowElement,
const item = itemById.get(nodeId);
if (!item) return;
bindPageRowEvents(row, item);
}); });
if (focusedNodeId) {
focusRowElement(focusedNodeId);
}
return true;
};
const syncFileTreeSelectionDom = () => { const syncFileTreeSelectionDom = () => {
if (mode !== "filetree") return; if (mode !== "filetree") return;
@@ -3333,56 +3289,30 @@ function startTreeShellRuntime() {
}); });
}; };
const focusPickerRowElement = (pickerItemKey) => { const focusPickerRowElement = (pickerItemKey) =>
const normalizedItemKey = normalizeText(pickerItemKey); focusTreeShellPickerRowElement({
window.requestAnimationFrame(() => { appElement,
const row = normalizeText,
normalizedItemKey === "__root__" }, pickerItemKey);
? 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 patchPickerActiveDom = () => { const patchPickerActiveDom = () =>
if (mode !== "picker") return; patchTreeShellPickerActiveDom({
appElement mode,
.querySelectorAll('[data-rust-rendered-row="picker"], [data-rust-rendered-row="picker-root"]') appElement,
.forEach((row) => { normalizeText,
if (!(row instanceof HTMLElement)) return; currentActivePickerItemKey,
const nodeId = normalizeText(row.dataset.nodeId); currentActiveDocumentId,
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 hydrateInitialPickerTree = () => { const hydrateInitialPickerTree = () =>
if (mode !== "picker") return false; hydrateTreeShellInitialPickerTree({
const root = appElement.querySelector('[data-rust-picker-renderer="initial_v1"]'); mode,
if (!(root instanceof HTMLElement)) { appElement,
return false; normalizeText,
} itemById,
root.querySelectorAll('[data-rust-rendered-row="picker-root"]').forEach((row) => { bindPickerRootEvents,
bindPickerRootEvents(row); 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 = () => { const hydrateInitialRenderer = () => {
if (mode === "page") { if (mode === "page") {
+5
View File
@@ -186,6 +186,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js", "/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
get(web_shell::tree_shell_picker_runtime_asset), 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( .route(
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js", "/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
get(web_shell::document_conflict_panel_runtime_asset), 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-icons-runtime.js",
"/api/mnote-browser-runtime/tree-shell-filetree-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-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-conflict-panel-runtime.js",
"/api/mnote-browser-runtime/document-pane-runtime.js", "/api/mnote-browser-runtime/document-pane-runtime.js",
"/api/mnote-browser-runtime/document-mindmap-host-runtime.js", "/api/mnote-browser-runtime/document-mindmap-host-runtime.js",
@@ -1008,6 +1008,20 @@ pub async fn tree_shell_picker_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty())) .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 { pub async fn document_conflict_panel_runtime_asset() -> Response {
// include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/) // include_str! 路径相对于当前源文件 (src/routes/web_shell.rs -> ../../browser/)
const JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js"); const JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js");