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 6d4ee294..8fa94981 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 @@ -417,6 +417,55 @@ CodeGraph 复核: - `codegraph_search startWithWebSocket` -> `rust/crates/mnote-web/browser/tree-live-controller.js` - `codegraph_search startWithSseFallback` -> `rust/crates/mnote-web/browser/tree-live-controller.js` +### 9.4 Batch 3 执行记录(2026-05-24) + +Reasonix implementation run: + +- run id: `reasonix-2026-05-24T14-22-14-401Z-ea414358` +- worktree: `/mnt/Data1T/mnote-wt-3-19-local-upload-b3` +- 结果:未采纳。该 run 卡在提交 plan 后没有产生工作区 diff,也没有写出 handoff;主控终止残留 runner / acp 进程并删除临时 worktree。 + +Codex 主控直接完成的最小切片: + +- 新增 `rust/crates/mnote-web/browser/local-upload-runtime.js`。 +- 新增固定 asset route:`GET /api/mnote-browser-runtime/local-upload-runtime.js`。 +- `PageLayout` 在 `SIDEBAR_TREE_JS` 前注入 `type="module"` 外置脚本。 +- `SIDEBAR_TREE_JS` 中以下上传/附件展示 helper 先做 runtime 代理,保留原实现 fallback: + - `uploadedAssetTitle` + - `uploadedAssetUrl` + - `localAssetOpenUrl` + - `uploadedAssetType` + - `fileTreeIconKindForFileName` + - `isLocalUploadedAsset` + - `uploadedAssetExtension` + - `isNonOfficeAttachmentName` + - `attachmentExtensionFromFileName` + - `isPdfAttachmentFileName` + - `isCodeAttachmentFileName` + - `inferCodeAttachmentLanguage` + - `attachmentClassForFileName` + - `uploadedAttachmentClass` + - `uploadedFileSize` + +采纳边界: + +- 本批次不迁移 `uploadFileToMediaAsset`、`insertUploadedAssetIntoEditor`、`uploadFilesWithResolvedTarget` 主流程。 +- 本批次不修改 `/api/local-folder/assets/upload` 请求字段、secondary pane 目标选择、resource tab、OnlyOffice、PDF/code 打开逻辑。 + +验证: + +```bash +node --check rust/crates/mnote-web/browser/local-upload-runtime.js +cargo fmt --manifest-path rust/Cargo.toml --all --check +cargo test -p mnote-web sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder +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、文件树滚动、标题来源、单附件删除保留相邻附件。 + ## 10. 分阶段执行清单 ### P0:定位辅助与基线保护 @@ -439,7 +488,7 @@ CodeGraph 复核: ### P1:外置 resource open / local upload adapter - [x] 新增 `rust/crates/mnote-web/browser/resource-open-runtime.js`。 -- [ ] 新增 `rust/crates/mnote-web/browser/local-upload-runtime.js`,或先与 resource open 合并为单个不超过 800 行的 `resource-open-runtime.js`。 +- [x] 新增 `rust/crates/mnote-web/browser/local-upload-runtime.js`,或先与 resource open 合并为单个不超过 800 行的 `resource-open-runtime.js`。 - [x] 第一刀只迁 `layout.rs` 中 resource open 的纯函数和 bridge;`web_shell.rs` 的 resource tab DOM、session、pane host 生命周期保持在后续 `document-pane-host-runtime.js` 批次。 - [ ] 从 `SIDEBAR_TREE_JS` / `web_shell.rs` 中迁出资源打开路径适配,不改变现有 open intent: - page row -> 当前 pane page tab diff --git a/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md b/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md index b2c34bd7..810c4c1f 100644 --- a/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md +++ b/design/05-editor-mainline/process/5-28-leptos-tiptap-editor-runtime-module-extraction-v1.md @@ -282,6 +282,7 @@ Codex 主控复核修正: - worker 初版把底层错误格式化为 `删除附件引用失败:{e}`,调用处也会加同一前缀;主控已改为 `error.to_string()`,避免用户反馈重复。 - worker 初版自报 `cargo check` 通过依赖临时 `reference-code` symlink;主控在 worktree 和主工作区分别重新验证。 - Reasonix runner 写出 completed 后底层 `reasonix acp` 残留,主控已清理本轮进程。 +- 后续 `task479` 暴露 DOM `range.selectNode(link)` + `Backspace` 场景下 Tiptap selection 可能未同步,导致相邻附件删除检查失败;主控已在 `history_safe_commands` 删除前把 DOM 选中的本地附件链接映射回 Tiptap text selection,再调用 `delete_selection()`。 已通过验证: @@ -293,6 +294,9 @@ Codex 主控复核修正: - 结果:`ok=true` - 步骤:`uploaded -> selected -> deleted -> undo -> redo` - 证据:`tmp/task488-local-attachment-link-delete-undo-smoke/result.json`、`tmp/task488-local-attachment-link-delete-undo-smoke/after-redo.png` +- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` + - 结果:`ok=true` + - 覆盖:单附件 `Backspace` 删除保留相邻附件、真实附件文件不被级联删除、刷新后被删引用不回来。 - `codegraph index . --force && codegraph status .` - 结果:索引 318 个文件,状态 up to date。 - 复核:`delete_selected_attachment_link_with_history`、`selected_local_attachment_link_text`、`is_local_attachment_link_selected` 均可通过 CodeGraph 搜索定位到 `editor_runtime/*`。 diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js new file mode 100644 index 00000000..cefda629 --- /dev/null +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -0,0 +1,161 @@ +// MNote 本地上传运行时外置模块。 +// 当前先承接 SIDEBAR_TREE_JS 中的纯辅助函数。 +// SIDEBAR_TREE_JS 会优先委托到 window.__mnoteLocalUploadRuntime; +// 模块未加载时,inline fallback 保持旧行为。 + +function uploadedAssetTitle(asset) { + return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件'; +} + +function uploadedAssetUrl(asset) { + return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); +} + +function currentRootUri() { + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('rootUri') || '').trim(); + if (fromUrl) return fromUrl; + return document.body instanceof HTMLElement + ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() + : ''; +} + +function localAssetOpenUrl(asset, download, context) { + if (!isLocalUploadedAsset(asset)) return ''; + var rootUri = String(context && context.rootUri || asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri(); + var rootRelativePath = String(asset && (asset.rootRelativePath || asset.root_relative_path) || '').trim(); + if (!rootUri || !rootRelativePath) return ''; + var url = new URL('/api/local-folder/files/open', window.location.origin); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('path', rootRelativePath); + if (download) url.searchParams.set('download', 'true'); + return url.toString(); +} + +function uploadedAssetType(asset) { + return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim(); +} + +function fileTreeIconKindForFileName(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; + if (['doc', 'docx', 'odt', 'rtf', 'ppt', 'pptx', 'odp', 'xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'office'; + if (ext === 'md' || ext === 'markdown') return 'markdown'; + if (ext === 'pdf') return 'pdf'; + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].indexOf(ext) >= 0) return 'image'; + return ''; +} + +function isLocalUploadedAsset(asset) { + var id = String(asset && asset.id || '').trim(); + return String(asset && asset.sourceKind || '').trim() === 'local_folder' || id.indexOf('local:asset:') === 0; +} + +function uploadedAssetExtension(asset) { + var match = uploadedAssetTitle(asset).toLowerCase().match(/\.([a-z0-9]+)$/); + return match ? match[1] : ''; +} + +function isNonOfficeAttachmentName(name, ext) { + var codeFileNames = [ + '.dockerignore', '.editorconfig', '.env', '.eslintrc', '.gitattributes', '.gitignore', '.npmrc', '.prettierrc', + 'dockerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile', 'procfile' + ]; + return [ + 'pdf', 'toml', 'json', 'yaml', 'yml', 'md', 'markdown', 'txt', 'ini', 'env', 'xml', 'html', 'htm', 'css', 'scss', + 'less', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'rs', 'go', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', + 'php', 'rb', 'sh', 'bash', 'zsh', 'sql', 'lock', 'log', 'vue', 'svelte', 'astro', 'jsonc', 'json5', + 'mts', 'cts', 'lua', 'dart', 'kt', 'kts', 'swift', 'scala', 'gradle', 'groovy', 'clj', + 'ex', 'exs', 'erl', 'hrl', 'fs', 'fsx', 'r', 'jl', 'm', 'mm', 'pl', 'pm', 'ps1', 'bat', 'cmd', + 'psm1', 'psd1', 'dockerfile', 'containerfile', 'proto', 'graphql', 'gql', 'prisma', 'tf', 'tfvars', + 'hcl', 'nix', 'cmake', 'bazel', 'bzl', 'properties', 'conf', 'cfg', 'config', 'service', 'desktop', + 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'prettierrc', 'eslintrc' + ].indexOf(ext) >= 0 || codeFileNames.indexOf(name) >= 0; +} + +function attachmentExtensionFromFileName(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + return name.indexOf('.') >= 0 ? name.split('.').pop() : ''; +} + +function isPdfAttachmentFileName(fileName) { + return attachmentExtensionFromFileName(fileName) === 'pdf'; +} + +function isCodeAttachmentFileName(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + var ext = attachmentExtensionFromFileName(name); + return ext !== 'pdf' && isNonOfficeAttachmentName(name, ext); +} + +function inferCodeAttachmentLanguage(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + var ext = attachmentExtensionFromFileName(name); + var byName = { + 'dockerfile': 'dockerfile', + 'makefile': 'makefile', + 'cmakelists.txt': 'cmake', + '.gitignore': 'gitignore', + '.gitattributes': 'gitattributes', + '.editorconfig': 'ini', + '.env': 'dotenv' + }; + if (byName[name]) return byName[name]; + var byExt = { + bash: 'bash', bat: 'batch', c: 'c', cjs: 'javascript', cmd: 'batch', conf: 'text', cpp: 'cpp', + cs: 'csharp', css: 'css', cts: 'typescript', dart: 'dart', dockerfile: 'dockerfile', env: 'dotenv', + go: 'go', gql: 'graphql', gradle: 'groovy', graphql: 'graphql', h: 'c', hcl: 'hcl', hpp: 'cpp', + htm: 'html', html: 'html', ini: 'ini', java: 'java', js: 'javascript', json: 'json', json5: 'json', + jsonc: 'jsonc', jsx: 'javascript', kt: 'kotlin', kts: 'kotlin', less: 'less', log: 'text', + lua: 'lua', m: 'objective-c', markdown: 'markdown', md: 'markdown', mjs: 'javascript', + mts: 'typescript', nix: 'nix', php: 'php', pl: 'perl', pm: 'perl', prisma: 'prisma', + proto: 'protobuf', ps1: 'powershell', py: 'python', r: 'r', rb: 'ruby', rs: 'rust', + scss: 'scss', sh: 'bash', sql: 'sql', svelte: 'svelte', swift: 'swift', tf: 'terraform', + tfvars: 'terraform', toml: 'toml', ts: 'typescript', tsx: 'typescript', txt: 'text', + vue: 'vue', xml: 'xml', yaml: 'yaml', yml: 'yaml', zsh: 'bash' + }; + return byExt[ext] || 'text'; +} + +function attachmentClassForFileName(fileName) { + var name = String(fileName || '').trim().toLowerCase(); + var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; + if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word'; + if (['ppt', 'pptx', 'odp'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt'; + if (['xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet'; + if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf'; + if (isNonOfficeAttachmentName(name, ext)) { + return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-code'; + } + return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file'; +} + +function uploadedAttachmentClass(asset) { + return attachmentClassForFileName(uploadedAssetTitle(asset)); +} + +function uploadedFileSize(asset) { + var size = Number(asset && (asset.file_size || asset.fileSize) || 0); + if (!Number.isFinite(size) || size <= 0) return ''; + if (size >= 1024 * 1024) return (size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2) + ' MB'; + if (size >= 1024) return (size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2) + ' KB'; + return String(Math.round(size)) + ' B'; +} + +window.__mnoteLocalUploadRuntime = { + uploadedAssetTitle: uploadedAssetTitle, + uploadedAssetUrl: uploadedAssetUrl, + localAssetOpenUrl: localAssetOpenUrl, + uploadedAssetType: uploadedAssetType, + fileTreeIconKindForFileName: fileTreeIconKindForFileName, + isLocalUploadedAsset: isLocalUploadedAsset, + uploadedAssetExtension: uploadedAssetExtension, + isNonOfficeAttachmentName: isNonOfficeAttachmentName, + attachmentExtensionFromFileName: attachmentExtensionFromFileName, + isPdfAttachmentFileName: isPdfAttachmentFileName, + isCodeAttachmentFileName: isCodeAttachmentFileName, + inferCodeAttachmentLanguage: inferCodeAttachmentLanguage, + attachmentClassForFileName: attachmentClassForFileName, + uploadedAttachmentClass: uploadedAttachmentClass, + uploadedFileSize: uploadedFileSize +}; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 6e62c543..18e8cd59 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -98,6 +98,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/resource-open-runtime.js", get(web_shell::resource_open_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/local-upload-runtime.js", + get(web_shell::local_upload_runtime_asset), + ) .route( "/api/mnote-browser-runtime/tree-live-controller.js", get(web_shell::tree_live_controller_runtime_asset), diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index c9614954..18b25b8f 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -4381,6 +4381,20 @@ pub async fn resource_open_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn local_upload_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/local-upload-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 tree_live_controller_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/tree-live-controller.js"); Response::builder() diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 31698b62..97452445 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -3140,15 +3140,27 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function localUploadRuntimeFunction(name) { + var runtime = window.__mnoteLocalUploadRuntime; + var fn = runtime && runtime[name]; + return typeof fn === 'function' ? fn : null; + } + function uploadedAssetTitle(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedAssetTitle'); + if (runtimeFn) return runtimeFn(asset); return String(asset && (asset.file_name || asset.title || asset.name) || '未命名附件').trim() || '未命名附件'; } function uploadedAssetUrl(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedAssetUrl'); + if (runtimeFn) return runtimeFn(asset); return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); } function localAssetOpenUrl(asset, download) { + var runtimeFn = localUploadRuntimeFunction('localAssetOpenUrl'); + if (runtimeFn) return runtimeFn(asset, download, { rootUri: currentRootUri() }); if (!isLocalUploadedAsset(asset)) return ''; var rootUri = String(asset && (asset.rootUri || asset.root_uri) || '').trim() || currentRootUri(); var rootRelativePath = String(asset && (asset.rootRelativePath || asset.root_relative_path) || '').trim(); @@ -3161,10 +3173,14 @@ const SIDEBAR_TREE_JS: &str = r##" } function uploadedAssetType(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedAssetType'); + if (runtimeFn) return runtimeFn(asset); return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim(); } function fileTreeIconKindForFileName(fileName) { + var runtimeFn = localUploadRuntimeFunction('fileTreeIconKindForFileName'); + if (runtimeFn) return runtimeFn(fileName); var name = String(fileName || '').trim().toLowerCase(); var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; if (['doc', 'docx', 'odt', 'rtf', 'ppt', 'pptx', 'odp', 'xls', 'xlsx', 'ods', 'csv'].indexOf(ext) >= 0) return 'office'; @@ -3179,16 +3195,22 @@ const SIDEBAR_TREE_JS: &str = r##" } function isLocalUploadedAsset(asset) { + var runtimeFn = localUploadRuntimeFunction('isLocalUploadedAsset'); + if (runtimeFn) return runtimeFn(asset); var id = String(asset && asset.id || '').trim(); return String(asset && asset.sourceKind || '').trim() === 'local_folder' || id.indexOf('local:asset:') === 0; } function uploadedAssetExtension(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedAssetExtension'); + if (runtimeFn) return runtimeFn(asset); var match = uploadedAssetTitle(asset).toLowerCase().match(/\.([a-z0-9]+)$/); return match ? match[1] : ''; } function isNonOfficeAttachmentName(name, ext) { + var runtimeFn = localUploadRuntimeFunction('isNonOfficeAttachmentName'); + if (runtimeFn) return runtimeFn(name, ext); var codeFileNames = [ '.dockerignore', '.editorconfig', '.env', '.eslintrc', '.gitattributes', '.gitignore', '.npmrc', '.prettierrc', 'dockerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile', 'procfile' @@ -3206,21 +3228,29 @@ const SIDEBAR_TREE_JS: &str = r##" } function attachmentExtensionFromFileName(fileName) { + var runtimeFn = localUploadRuntimeFunction('attachmentExtensionFromFileName'); + if (runtimeFn) return runtimeFn(fileName); var name = String(fileName || '').trim().toLowerCase(); return name.indexOf('.') >= 0 ? name.split('.').pop() : ''; } function isPdfAttachmentFileName(fileName) { + var runtimeFn = localUploadRuntimeFunction('isPdfAttachmentFileName'); + if (runtimeFn) return runtimeFn(fileName); return attachmentExtensionFromFileName(fileName) === 'pdf'; } function isCodeAttachmentFileName(fileName) { + var runtimeFn = localUploadRuntimeFunction('isCodeAttachmentFileName'); + if (runtimeFn) return runtimeFn(fileName); var name = String(fileName || '').trim().toLowerCase(); var ext = attachmentExtensionFromFileName(name); return ext !== 'pdf' && isNonOfficeAttachmentName(name, ext); } function inferCodeAttachmentLanguage(fileName) { + var runtimeFn = localUploadRuntimeFunction('inferCodeAttachmentLanguage'); + if (runtimeFn) return runtimeFn(fileName); var name = String(fileName || '').trim().toLowerCase(); var ext = attachmentExtensionFromFileName(name); var byName = { @@ -3250,6 +3280,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function attachmentClassForFileName(fileName) { + var runtimeFn = localUploadRuntimeFunction('attachmentClassForFileName'); + if (runtimeFn) return runtimeFn(fileName); var name = String(fileName || '').trim().toLowerCase(); var ext = name.indexOf('.') >= 0 ? name.split('.').pop() : ''; if (['doc', 'docx', 'odt', 'rtf'].indexOf(ext) >= 0) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word'; @@ -3263,6 +3295,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function uploadedAttachmentClass(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedAttachmentClass'); + if (runtimeFn) return runtimeFn(asset); return attachmentClassForFileName(uploadedAssetTitle(asset)); } @@ -3296,6 +3330,8 @@ const SIDEBAR_TREE_JS: &str = r##" } function uploadedFileSize(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedFileSize'); + if (runtimeFn) return runtimeFn(asset); var size = Number(asset && (asset.file_size || asset.fileSize) || 0); if (!Number.isFinite(size) || size <= 0) return ''; if (size >= 1024 * 1024) return (size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2) + ' MB'; @@ -10417,6 +10453,7 @@ pub fn PageLayout( + diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 7988fc76..4aed409a 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -360,6 +360,10 @@ function __wbg_get_imports() { const ret = arg0.call(arg1); return ret; }, arguments); }, + __wbg_call_86e39d65afc3d9db: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.call(arg1, arg2, arg3, arg4); + return ret; + }, arguments); }, __wbg_call_a24592a6f349a97e: function() { return handleError(function (arg0, arg1, arg2) { const ret = arg0.call(arg1, arg2); return ret; @@ -846,6 +850,10 @@ function __wbg_get_imports() { state0.a = 0; } }, + __wbg_new_with_args_57b505df22335acd: function(arg0, arg1, arg2, arg3) { + const ret = new Function(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3)); + return ret; + }, __wbg_new_with_byte_offset_and_length_01848e8d6a3d49ad: function(arg0, arg1, arg2) { const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0); return ret; @@ -1251,47 +1259,47 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1188, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1187, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 964, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 963, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1086, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1085, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1136, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1135, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 297, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 745, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd90af689bc3e71bf); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1088, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1087, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f); return ret; }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1103, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1102, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5); return ret; }, __wbindgen_cast_0000000000000009: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1138, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c); return ret; }, diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index c0d907e8..d385e0f2 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs index 1d3db3e5..55fc3e39 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/attachment_links.rs @@ -1,3 +1,4 @@ +use leptos_tiptap::TiptapRange; use wasm_bindgen::{JsCast, JsValue}; use web_sys::{window, Element}; @@ -9,20 +10,17 @@ fn editor_root_element() -> Option { .and_then(|document| document.query_selector(EDITOR_ROOT_SELECTOR).ok().flatten()) } -/// 当前 DOM 选中文本匹配本地附件链接文本时返回 `true`。 -pub(crate) fn selected_local_attachment_link_text() -> bool { +fn selected_local_attachment_link_dom_info() -> Option<(String, String)> { let selection = match window().and_then(|win| win.get_selection().ok().flatten()) { Some(selection) if !selection.is_collapsed() => selection, - _ => return false, + _ => return None, }; let selected_text = String::from(selection.to_string()).trim().to_string(); if selected_text.is_empty() { - return false; + return None; } - let Some(root) = editor_root_element() else { - return false; - }; + let root = editor_root_element()?; let links = root.get_elements_by_tag_name("a"); for index in 0..links.length() { let Some(link) = links.item(index) else { @@ -33,11 +31,16 @@ pub(crate) fn selected_local_attachment_link_text() -> bool { continue; } if link.text_content().unwrap_or_default().trim() == selected_text { - return true; + return Some((selected_text, href)); } } - false + None +} + +/// 当前 DOM 选中文本匹配本地附件链接文本时返回 `true`。 +pub(crate) fn selected_local_attachment_link_text() -> bool { + selected_local_attachment_link_dom_info().is_some() } /// 当前非空选区命中本地附件链接 mark 时返回 `true`。 @@ -85,3 +88,54 @@ pub(crate) fn selected_local_attachment_link_in_editor_state() -> bool { pub(crate) fn is_local_attachment_link_selected() -> bool { selected_local_attachment_link_in_editor_state() || selected_local_attachment_link_text() } + +pub(crate) fn selected_local_attachment_link_range() -> Option { + let (selected_text, selected_href) = selected_local_attachment_link_dom_info()?; + let root = editor_root_element()?; + let editor_value = js_sys::Reflect::get(root.as_ref(), &JsValue::from_str("editor")).ok()?; + let find_range = js_sys::Function::new_with_args( + "editor, selectedText, selectedHref", + r#" + if (!editor || !editor.state || !editor.state.doc) return null; + var text = String(selectedText || '').trim(); + var hrefNeedle = String(selectedHref || '').trim(); + if (!text) return null; + var match = null; + editor.state.doc.descendants(function(node, pos) { + if (match || !node || !node.isText) return; + var nodeText = String(node.text || ''); + var offset = nodeText.indexOf(text); + if (offset < 0) return; + var hasAttachmentLink = Array.isArray(node.marks) && node.marks.some(function(mark) { + var href = mark && mark.attrs ? String(mark.attrs.href || '') : ''; + return mark.type && mark.type.name === 'link' + && href.indexOf('/api/local-folder/files/open') >= 0 + && (!hrefNeedle || href === hrefNeedle); + }); + if (!hasAttachmentLink) return; + match = { from: pos + offset, to: pos + offset + text.length }; + }); + return match; + "#, + ); + let range_value = find_range + .call3( + &JsValue::NULL, + &editor_value, + &JsValue::from_str(&selected_text), + &JsValue::from_str(&selected_href), + ) + .ok()?; + if range_value.is_null() || range_value.is_undefined() { + return None; + } + let from = js_sys::Reflect::get(&range_value, &JsValue::from_str("from")) + .ok() + .and_then(|value| value.as_f64()) + .filter(|value| value.is_finite() && *value >= 0.0)? as u32; + let to = js_sys::Reflect::get(&range_value, &JsValue::from_str("to")) + .ok() + .and_then(|value| value.as_f64()) + .filter(|value| value.is_finite() && *value >= from as f64)? as u32; + Some(TiptapRange { from, to }) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs index db3ee2ad..40461be7 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/history_safe_commands.rs @@ -12,6 +12,9 @@ pub(crate) fn delete_selected_attachment_link_with_history( if !attachment_links::is_local_attachment_link_selected() { return Err("当前选区不是本地附件链接".to_string()); } + if let Some(range) = attachment_links::selected_local_attachment_link_range() { + let _ = editor.set_text_selection(range); + } // 继续走 Tiptap 命令路径,保留撤销/重做历史。 editor.delete_selection().map_err(|error| error.to_string()) }