diff --git a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md b/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md index 91ee4429..1f1daba6 100644 --- a/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md +++ b/design/03-rust-web/process/3-19-rust-web-browser-runtime-module-extraction-v1.md @@ -466,6 +466,36 @@ node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js - `local-upload-runtime.js` route 返回 `200`,`content-type=application/javascript; charset=utf-8`。 - `task479` 最终 `ok=true`,覆盖主编辑区上传目录、filetree folder drop、broken link 保留、无 active document 附件 tab、文件树滚动、标题来源、单附件删除保留相邻附件。 +### 9.5 Batch 4 执行记录(2026-05-24) + +Reasonix read-only audit: + +- run id: `reasonix-2026-05-24T15-27-18-395Z-12afda31` +- worker worktree: `/mnt/Data1T/mnote-wt-3-19-local-upload-context-b4` +- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T15-27-18-395Z-12afda31/process-handoff.md` +- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、`process-handoff.json`。 + +采纳内容: + +- `local-upload-runtime.js` 新增 `resolveEditorUploadContext`、`editorRootFromUploadOptions`、`openEditorUploadFilePicker`。 +- `SIDEBAR_TREE_JS` 中保留同名 wrapper 和 inline fallback;优先委托 `window.__mnoteLocalUploadRuntime`,模块加载失败时保留旧行为。 +- 本批不迁 `uploadFileToMediaAsset`、`uploadFilesWithResolvedTarget`、`insertUploadedAssetIntoEditor`。这些函数仍混合 API POST、file tree refresh、Tiptap chain 插入和 OnlyOffice/resource tab 逻辑,后续需要继续拆边界。 +- `scripts/task472-side-target-secondary-pane-smoke.js` 补齐 `/api/local-folder/assets/upload` 当前必需的 `uploadIntent=editor.markdown.attach`,让 secondary pane smoke 恢复可运行。 + +已通过验证: + +- `node --check rust/crates/mnote-web/browser/local-upload-runtime.js` +- `cargo fmt --manifest-path rust/Cargo.toml --all --check` +- `cargo check --manifest-path rust/Cargo.toml -p mnote-web` +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_upload_runtime_contains_editor_upload_context_helpers` +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder` +- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` + - 结果:`ok=true` + - 证据:命令 stdout JSON,覆盖 `editor-upload-to-page-resource-dir`、`filetree-folder-drop-to-target`、`broken-link-after-real-file-delete`、`direct-attach-open-without-active-md`、`editor-delete-one-attachment-preserves-adjacent` 等检查。 +- `node scripts/task472-side-target-secondary-pane-smoke.js` + - 结果:`ok=true` + - 证据:命令 stdout JSON,`assetId=local:asset:README/side-target-resource.md`、`officeAssetId=local:asset:README/side-target-office.docx`。 + ## 10. 分阶段执行清单 ### P0:定位辅助与基线保护 @@ -498,7 +528,7 @@ node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js - office -> OnlyOffice path builder - [ ] 从 `SIDEBAR_TREE_JS` 中迁出本地文件夹上传目标计算和 adapter 调用;资源归属语义仍由 Rust API / kernel 合同决定。 - 2026-05-24 / `reasonix-2026-05-24T15-17-16-430Z-18054487` 只读审计:`leptos-tiptap` 只应发出上传 intent;本地 Markdown 路径、`rootUri`、`/api/local-folder/files/open` URL、`markdownRelativePath` 与真实上传落盘都归本设计的 shell host runtime。 - - 下一刀低风险候选:`resolveEditorUploadContext`、`editorRootFromUploadOptions`、`openEditorUploadFilePicker`,优先保持调用 `uploadFilesWithResolvedTarget`,不同时迁上传主流程。 + - 2026-05-24 / Batch 4 已迁:`resolveEditorUploadContext`、`editorRootFromUploadOptions`、`openEditorUploadFilePicker`,并保持调用 `uploadFilesWithResolvedTarget`,未同时迁上传主流程。 - 暂不迁:`uploadFileToMediaAsset`、`uploadFilesWithResolvedTarget`、`insertUploadedAssetIntoEditor`。这些函数同时涉及 API POST、file tree refresh、Tiptap chain 插入、OnlyOffice/resource tab,需在后续批次继续拆边界。 - [ ] 保持现有 smoke: - `node scripts/task472-side-target-secondary-pane-smoke.js` diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js index cefda629..0508d7d7 100644 --- a/rust/crates/mnote-web/browser/local-upload-runtime.js +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -20,6 +20,110 @@ function currentRootUri() { : ''; } +function editorUploadRootFromElementWithDeps(target, deps) { + if (deps && typeof deps.editorUploadRootFromElement === 'function') { + return deps.editorUploadRootFromElement(target); + } + if (!(target instanceof Element)) return null; + var selector = String(deps && deps.rootSelector || '[data-editor-host-kind="leptos_tiptap_island"], [data-editor-host-kind="leptos_tiptap_resource"]'); + var root = target.closest(selector); + return root instanceof HTMLElement ? root : null; +} + +function resolveEditorUploadContext(detail, deps) { + var root = null; + var selector = detail && detail.editorRootSelector ? String(detail.editorRootSelector) : ''; + if (selector) { + try { + var selected = document.querySelector(selector); + if (selected instanceof HTMLElement) root = selected; + } catch (_) {} + } + if (!root && window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) { + root = window.__mnoteIntendedSlashRoot; + } + if (!root && window.__mnoteLastEditorUploadRoot instanceof HTMLElement && window.__mnoteLastEditorUploadRoot.isConnected) { + root = window.__mnoteLastEditorUploadRoot; + } + if (!root && document.activeElement instanceof Element) { + root = editorUploadRootFromElementWithDeps(document.activeElement, deps); + } + if (!root) { + var rootSelector = String(deps && deps.rootSelector || '[data-editor-host-kind="leptos_tiptap_island"], [data-editor-host-kind="leptos_tiptap_resource"]'); + var focused = document.querySelector(rootSelector + ' .ProseMirror:focus-within'); + root = editorUploadRootFromElementWithDeps(focused, deps); + } + if (!root) { + root = document.querySelector('[data-editor-host-kind="leptos_tiptap_island"][data-pane-role="primary"]'); + } + var pane = root instanceof Element ? root.closest('.document-pane[data-pane-role]') : null; + var shell = root instanceof Element ? root.closest('.document-shell[data-document-id]') : null; + var currentDocumentId = deps && typeof deps.currentDocumentId === 'function' ? deps.currentDocumentId : function() { return ''; }; + var resolveWorkspaceId = deps && typeof deps.resolveWorkspaceId === 'function' ? deps.resolveWorkspaceId : function() { return ''; }; + return { + root: root instanceof HTMLElement ? root : null, + documentId: String( + detail && detail.documentId + || (root instanceof HTMLElement && root.getAttribute('data-document-id')) + || (pane instanceof HTMLElement && pane.getAttribute('data-pane-document-id')) + || (shell instanceof HTMLElement && shell.getAttribute('data-document-id')) + || currentDocumentId() + || '' + ).trim(), + workspaceId: String( + detail && detail.workspaceId + || (root instanceof HTMLElement && root.getAttribute('data-workspace-id')) + || (pane instanceof HTMLElement && pane.getAttribute('data-pane-workspace-id')) + || (shell instanceof HTMLElement && shell.getAttribute('data-workspace-id')) + || resolveWorkspaceId(document.body) + || '' + ).trim() + }; +} + +function editorRootFromUploadOptions(options, deps) { + if (options && options.editorRoot instanceof HTMLElement) return options.editorRoot; + if (window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) { + return window.__mnoteIntendedSlashRoot; + } + if (window.__mnoteLastEditorUploadRoot instanceof HTMLElement && window.__mnoteLastEditorUploadRoot.isConnected) { + return window.__mnoteLastEditorUploadRoot; + } + var rootSelector = String(deps && deps.rootSelector || '[data-editor-host-kind="leptos_tiptap_island"], [data-editor-host-kind="leptos_tiptap_resource"]'); + var focused = document.querySelector(rootSelector + ' .ProseMirror:focus-within'); + return editorUploadRootFromElementWithDeps(focused, deps); +} + +function openEditorUploadFilePicker(detail, deps) { + var uploadContext = resolveEditorUploadContext(detail || {}, deps || {}); + var input = document.createElement('input'); + input.type = 'file'; + input.multiple = detail && detail.multiple !== false; + if (detail && detail.accept) input.accept = String(detail.accept); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + input.style.top = '-9999px'; + document.body.appendChild(input); + input.addEventListener('change', function() { + var files = Array.from(input.files || []); + input.remove(); + var resolveWorkspaceId = deps && typeof deps.resolveWorkspaceId === 'function' ? deps.resolveWorkspaceId : function() { return ''; }; + var currentDocumentId = deps && typeof deps.currentDocumentId === 'function' ? deps.currentDocumentId : function() { return ''; }; + var uploadFilesWithResolvedTarget = deps && typeof deps.uploadFilesWithResolvedTarget === 'function' ? deps.uploadFilesWithResolvedTarget : null; + if (!uploadFilesWithResolvedTarget) return; + void uploadFilesWithResolvedTarget(files, { + workspaceId: uploadContext.workspaceId || resolveWorkspaceId(document.body), + documentId: uploadContext.documentId || currentDocumentId(), + targetRowId: null, + uploadIntent: 'editor.markdown.attach' + }, { + insertIntoEditor: detail && detail.insertIntoEditor !== false, + editorRoot: uploadContext.root + }); + }, { once: true }); + input.click(); +} + function localAssetOpenUrl(asset, download, context) { if (!isLocalUploadedAsset(asset)) return ''; var rootUri = String(context && context.rootUri || asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri(); @@ -145,6 +249,9 @@ function uploadedFileSize(asset) { window.__mnoteLocalUploadRuntime = { uploadedAssetTitle: uploadedAssetTitle, uploadedAssetUrl: uploadedAssetUrl, + resolveEditorUploadContext: resolveEditorUploadContext, + editorRootFromUploadOptions: editorRootFromUploadOptions, + openEditorUploadFilePicker: openEditorUploadFilePicker, localAssetOpenUrl: localAssetOpenUrl, uploadedAssetType: uploadedAssetType, fileTreeIconKindForFileName: fileTreeIconKindForFileName, diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 97452445..751bc7e4 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3775,6 +3775,15 @@ const SIDEBAR_TREE_JS: &str = r##" } function resolveEditorUploadContext(detail) { + var runtimeFn = localUploadRuntimeFunction('resolveEditorUploadContext'); + if (runtimeFn) { + return runtimeFn(detail || {}, { + rootSelector: EDITOR_UPLOAD_ROOT_SELECTOR, + editorUploadRootFromElement: editorUploadRootFromElement, + currentDocumentId: currentDocumentId, + resolveWorkspaceId: resolveWorkspaceId + }); + } var root = null; var selector = detail && detail.editorRootSelector ? String(detail.editorRootSelector) : ''; if (selector) { @@ -3823,6 +3832,13 @@ const SIDEBAR_TREE_JS: &str = r##" } function editorRootFromUploadOptions(options) { + var runtimeFn = localUploadRuntimeFunction('editorRootFromUploadOptions'); + if (runtimeFn) { + return runtimeFn(options || {}, { + rootSelector: EDITOR_UPLOAD_ROOT_SELECTOR, + editorUploadRootFromElement: editorUploadRootFromElement + }); + } if (options && options.editorRoot instanceof HTMLElement) return options.editorRoot; if (window.__mnoteIntendedSlashRoot instanceof HTMLElement && window.__mnoteIntendedSlashRoot.isConnected) { return window.__mnoteIntendedSlashRoot; @@ -4029,6 +4045,16 @@ const SIDEBAR_TREE_JS: &str = r##" } function openEditorUploadFilePicker(detail) { + var runtimeFn = localUploadRuntimeFunction('openEditorUploadFilePicker'); + if (runtimeFn) { + return runtimeFn(detail || {}, { + rootSelector: EDITOR_UPLOAD_ROOT_SELECTOR, + editorUploadRootFromElement: editorUploadRootFromElement, + currentDocumentId: currentDocumentId, + resolveWorkspaceId: resolveWorkspaceId, + uploadFilesWithResolvedTarget: uploadFilesWithResolvedTarget + }); + } var uploadContext = resolveEditorUploadContext(detail || {}); var input = document.createElement('input'); input.type = 'file'; @@ -10795,12 +10821,29 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot")); assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed")); assert!(SIDEBAR_TREE_JS.contains("function resolveEditorUploadContext")); + assert!( + SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('resolveEditorUploadContext')") + ); + assert!( + SIDEBAR_TREE_JS.contains("localUploadRuntimeFunction('openEditorUploadFilePicker')") + ); assert!(SIDEBAR_TREE_JS.contains("__mnoteLastEditorUploadRoot")); assert!(SIDEBAR_TREE_JS.contains("data-pane-role=\"primary\"")); assert!(SIDEBAR_TREE_JS.contains("insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options))")); assert!(SIDEBAR_TREE_JS.contains("editorRoot: uploadContext.root")); } + #[test] + fn local_upload_runtime_contains_editor_upload_context_helpers() { + const LOCAL_UPLOAD_RUNTIME_JS: &str = + include_str!("../../../browser/local-upload-runtime.js"); + assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function resolveEditorUploadContext")); + assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function editorRootFromUploadOptions")); + assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function openEditorUploadFilePicker")); + assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("uploadFilesWithResolvedTarget")); + assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot")); + } + #[test] fn sidebar_tree_runtime_focuses_restored_local_folder_row() { assert!(SIDEBAR_TREE_JS.contains("applyPendingLocalFolderRestoreFocusOnce")); diff --git a/scripts/task472-side-target-secondary-pane-smoke.js b/scripts/task472-side-target-secondary-pane-smoke.js index 921a4ce7..e50d73d5 100644 --- a/scripts/task472-side-target-secondary-pane-smoke.js +++ b/scripts/task472-side-target-secondary-pane-smoke.js @@ -64,6 +64,7 @@ async function uploadLocalAsset(page, root, documentId, fileName, mimeType, byte const form = new FormData(); form.append("rootUri", rootUri); form.append("documentId", documentId); + form.append("uploadIntent", "editor.markdown.attach"); form.append("kind", kind); form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType })); const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form });