refactor: split tree shell dom runtime
This commit is contained in:
+1
-1
@@ -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") {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user