From 990df30a0f9e6851393fa72c8dea0e7ba37e29c6 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 00:35:35 +0800 Subject: [PATCH] refactor: externalize document editor adapter runtime --- ...ime-module-maintainability-checklist-v1.md | 16 +- .../document-editor-adapter-runtime.js | 3787 +++++++++++++++ rust/crates/mnote-web/src/routes/gateway.rs | 8 +- rust/crates/mnote-web/src/routes/mod.rs | 5 + rust/crates/mnote-web/src/routes/web_shell.rs | 4077 +---------------- 5 files changed, 3968 insertions(+), 3925 deletions(-) create mode 100644 rust/crates/mnote-web/browser/document-editor-adapter-runtime.js 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 243f70cf..7f37763b 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 @@ -85,14 +85,14 @@ rust/spikes/leptos-tiptap-spike/src/lib.rs 7,951 行 拆分项: -- [ ] A1. 把 `render_editor_island_adapter_script()` 的大型 `` 主体迁入 `document-editor-adapter-runtime.js`。 -- [ ] A2. `web_shell.rs` 改为输出 ``。 -- [ ] A3. 新增 asset route:`/api/mnote-browser-runtime/document-editor-adapter-runtime.js`。 -- [ ] A4. 保留并更新测试断言,至少覆盖: - - [ ] script tag 出现在 document shell HTML。 - - [ ] runtime asset 包含 `PANES_BOOTSTRAP_ID`、`ROOT_SELECTOR`、`BRIDGE_PROTOCOL`。 - - [ ] runtime asset 包含 `legacyInlineContentToTiptap`、`openSecondaryDocument`、`openPrimaryMindmap`。 -- [ ] A5. `web_shell.rs` 行数显著下降,目标少于 3,200 行。 +- [x] A1. 把 `render_editor_island_adapter_script()` 的大型 `` 主体迁入 `document-editor-adapter-runtime.js`。 +- [x] A2. `web_shell.rs` 改为输出 ``。 +- [x] A3. 新增 asset route:`/api/mnote-browser-runtime/document-editor-adapter-runtime.js`。 +- [x] A4. 保留并更新测试断言,至少覆盖: + - [x] script tag 出现在 document shell HTML。 + - [x] runtime asset 包含 `PANES_BOOTSTRAP_ID`、`ROOT_SELECTOR`、`BRIDGE_PROTOCOL`。 + - [x] runtime asset 包含 `legacyInlineContentToTiptap`、`openSecondaryDocument`、`openPrimaryMindmap`。 +- [x] A5. `web_shell.rs` 行数显著下降,目标少于 3,200 行。 验收命令: diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js new file mode 100644 index 00000000..2970e1a9 --- /dev/null +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -0,0 +1,3787 @@ +(() => { + const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__'; + const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]'; + const EVENT_PREFIX = 'mnote:leptos-tiptap-spike'; + const CHANGE_EVENT = `${EVENT_PREFIX}:change`; + const SAVE_EVENT = `${EVENT_PREFIX}:save-request`; + const READY_EVENT = `${EVENT_PREFIX}:ready`; + const STATE_EVENT = `${EVENT_PREFIX}:state`; + const ERROR_EVENT = `${EVENT_PREFIX}:error`; + const COMMAND_EVENT = `${EVENT_PREFIX}:command`; + const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1'; + + const parseJsonScript = (id) => { + const node = document.getElementById(id); + if (!node) return null; + try { + return JSON.parse(node.textContent || 'null'); + } catch (error) { + console.warn(`mnote rust-web editor bootstrap JSON 解析失败: ${id}`, error); + return null; + } + }; + + const panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID); + if (!panesBootstrap || !Array.isArray(panesBootstrap.panes)) return; + + const paneRouteConfig = { + primary: { + sourceKindParam: 'sourceKind', + rootUriParam: 'rootUri', + secondary: false, + }, + secondary: { + documentIdParam: 'secondaryDocumentId', + sourceKindParam: 'secondarySourceKind', + rootUriParam: 'secondaryRootUri', + secondary: true, + }, + }; + const secondaryQueryParamNames = [ + paneRouteConfig.secondary.documentIdParam, + paneRouteConfig.secondary.sourceKindParam, + paneRouteConfig.secondary.rootUriParam, + ]; + const currentUrl = () => new URL(window.location.href); + const replaceUrlState = (url) => { + window.history.replaceState({}, '', url.pathname + url.search + url.hash); + }; + const pushUrlState = (url) => { + window.history.pushState({}, '', url.pathname + url.search + url.hash); + }; + const clearSecondaryParams = () => { + const url = currentUrl(); + secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); + replaceUrlState(url); + }; + if (panesBootstrap.secondaryInvalid === true) clearSecondaryParams(); + + const secondaryUrlForDocument = (documentId, detail = {}) => { + const url = currentUrl(); + url.searchParams.set(paneRouteConfig.secondary.documentIdParam, documentId); + const detailSourceKind = typeof detail.sourceKind === 'string' ? detail.sourceKind.trim() : ''; + const detailRootUri = typeof detail.rootUri === 'string' ? detail.rootUri.trim() : ''; + const primarySourceKind = (url.searchParams.get(paneRouteConfig.primary.sourceKindParam) || '').trim(); + const primaryRootUri = (url.searchParams.get(paneRouteConfig.primary.rootUriParam) || '').trim(); + const secondarySourceKind = detailSourceKind || primarySourceKind; + const secondaryRootUri = detailRootUri || primaryRootUri; + if (secondarySourceKind) url.searchParams.set(paneRouteConfig.secondary.sourceKindParam, secondarySourceKind); + else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam); + if (secondaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, secondaryRootUri); + else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam); + return { url, sourceKind: secondarySourceKind, rootUri: secondaryRootUri }; + }; + + const openDocumentInSecondaryPane = (documentId, detail = {}) => { + const id = typeof documentId === 'string' ? documentId.trim() : ''; + if (!id) return false; + const target = secondaryUrlForDocument(id, detail); + if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') { + void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({ + documentId: id, + workspaceId: typeof detail.workspaceId === 'string' ? detail.workspaceId.trim() : '', + sourceKind: target.sourceKind || null, + rootUri: target.rootUri || null, + url: target.url, + }); + return true; + } + window.location.assign(target.url.pathname + target.url.search + target.url.hash); + return true; + }; + + window.addEventListener('tree.page.open-right', (event) => { + const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {}; + const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : ''; + openDocumentInSecondaryPane(documentId, detail); + }); + + window.addEventListener('tree.page.open', (event) => { + const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {}; + const openTarget = typeof detail.openTarget === 'string' ? detail.openTarget.trim().toLowerCase() : ''; + if (openTarget !== 'side') return; + const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : ''; + openDocumentInSecondaryPane(documentId, detail); + }); + + document.querySelectorAll('[data-mnote-pane-close="secondary"]').forEach((button) => { + button.addEventListener('click', (event) => { + event.preventDefault(); + const url = currentUrl(); + secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); + if (typeof window.__mnoteDocumentPaneRuntime?.closeSecondaryDocument === 'function') { + window.__mnoteDocumentPaneRuntime.closeSecondaryDocument({ url }); + return; + } + window.location.assign(url.pathname + url.search + url.hash); + }); + }); + + const workspace = document.querySelector('[data-testid="mnote-document-workspace"]'); + const resizer = document.querySelector('[data-document-pane-resizer="true"]'); + const SECONDARY_WIDTH_KEY = 'mnote.document.secondary.width'; + const applyStoredSecondaryWidth = () => { + if (!(workspace instanceof HTMLElement)) return; + if (workspace.getAttribute('data-has-secondary-pane') !== 'true') { + workspace.style.removeProperty('grid-template-columns'); + return; + } + try { + const raw = window.localStorage ? window.localStorage.getItem(SECONDARY_WIDTH_KEY) : ''; + const width = Number(raw || 0); + if (Number.isFinite(width) && width >= 320) { + workspace.style.gridTemplateColumns = `minmax(0, 1fr) 6px minmax(320px, ${Math.round(width)}px)`; + } + } catch (_) {} + }; + applyStoredSecondaryWidth(); + if (workspace instanceof HTMLElement && resizer instanceof HTMLElement) { + resizer.addEventListener('pointerdown', (event) => { + if (workspace.getAttribute('data-has-secondary-pane') !== 'true') return; + event.preventDefault(); + const move = (nextEvent) => { + const rect = workspace.getBoundingClientRect(); + const width = Math.min(Math.max(320, rect.right - nextEvent.clientX), Math.max(420, rect.width - 360)); + workspace.style.gridTemplateColumns = `minmax(0, 1fr) 6px minmax(320px, ${Math.round(width)}px)`; + try { + if (window.localStorage) window.localStorage.setItem(SECONDARY_WIDTH_KEY, String(Math.round(width))); + } catch (_) {} + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }); + } + + const buildPaneRuntime = (paneDescriptor) => { + const paneRole = typeof paneDescriptor?.role === 'string' ? paneDescriptor.role : 'primary'; + const paneRoot = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); + const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`); + if (!(paneRoot instanceof HTMLElement)) return null; + if (!paneDescriptor?.aggregate || !paneDescriptor?.bootstrap) return null; + return { + paneRole, + root: paneRoot, + observability, + aggregate: paneDescriptor.aggregate, + bootstrap: paneDescriptor.bootstrap, + }; + }; + + const paneRuntimes = panesBootstrap.panes.map(buildPaneRuntime).filter(Boolean); + + const loadRuntime = async () => { + if (window.__mnoteLeptosTiptapRuntimePromise) { + return window.__mnoteLeptosTiptapRuntimePromise; + } + window.__mnoteLeptosTiptapRuntimePromise = (async () => { + const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' }); + if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`); + const manifest = await manifestResponse.json(); + if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath'); + const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`; + const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined; + const runtime = await import(entryUrl); + if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') { + throw new Error('island runtime 导出不完整'); + } + await runtime.default({ module_or_path: wasmUrl }); + if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') { + window.__MNOTE_MINDMAP_RUST_SHELL__ = { + mount: runtime.mount_mindmap_shell, + unmount: runtime.unmount_mindmap_shell, + }; + } + return runtime; + })(); + return window.__mnoteLeptosTiptapRuntimePromise; + }; + + const flattenText = (value) => { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(flattenText).join(''); + if (value && typeof value === 'object') { + return `${flattenText(value.text)}${flattenText(value.content)}`; + } + return ''; + }; + + const firstNonEmptyText = (...values) => { + for (const value of values) { + const text = flattenText(value).trim(); + if (text) return text; + } + return ''; + }; + + // 过渡适配(TODO step-4):legacy→Tiptap inline marks 转换函数组。 + // AST/block 迁移 complete 后,前端应直接消费 block document 中的 + // tiptap 格式 marks(已由 Rust 侧 local_markdown_parser 输出), + // 不再需要此 JS 侧样式→marks 适配层。 + const legacyStylesToTiptapMarks = (styles) => { + if (!styles || typeof styles !== 'object') return []; + const marks = []; + if (styles.bold) marks.push({ type: 'bold' }); + if (styles.italic) marks.push({ type: 'italic' }); + if (styles.underline) marks.push({ type: 'underline' }); + if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' }); + if (styles.code || styles.inlineCode) marks.push({ type: 'code' }); + const href = typeof styles.link === 'string' && styles.link.trim() + ? styles.link.trim() + : typeof styles.href === 'string' && styles.href.trim() + ? styles.href.trim() + : ''; + if (href) marks.push({ type: 'link', attrs: { href } }); + return marks; + }; + + const legacyMarkArrayToTiptapMarks = (inlineMarks) => { + if (!Array.isArray(inlineMarks)) return []; + return inlineMarks.flatMap((mark) => { + if (!mark || typeof mark !== 'object') return []; + if (mark.type === 'bold' || mark.type === 'italic' || mark.type === 'underline' || mark.type === 'strike' || mark.type === 'code') { + return [{ type: mark.type }]; + } + if (mark.type === 'link') { + const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; + return href ? [{ type: 'link', attrs: { href } }] : []; + } + return []; + }); + }; + + const mergeTiptapMarks = (...groups) => { + const seen = new Set(); + return groups.flat().filter((mark) => { + const key = `${mark.type}:${JSON.stringify(mark.attrs || {})}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + + const legacyInlineContentToTiptap = (value) => { + if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : []; + if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap); + if (value && typeof value === 'object') { + const text = typeof value.text === 'string' ? value.text : ''; + if (text) { + const marks = mergeTiptapMarks( + legacyStylesToTiptapMarks(value.styles), + legacyMarkArrayToTiptapMarks(value.marks) + ); + return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }]; + } + return legacyInlineContentToTiptap(value.content || value.contentNodes); + } + return []; + }; + + const legacyBlockToTiptap = (block, index = 0) => { + const type = typeof block?.type === 'string' ? block.type : 'paragraph'; + const blockId = typeof block?.id === 'string' && block.id.trim() + ? block.id.trim() + : typeof block?.blockId === 'string' && block.blockId.trim() + ? block.blockId.trim() + : `block-${index + 1}`; + const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes); + const textAlign = typeof block?.props?.textAlign === 'string' + ? block.props.textAlign + : typeof block?.props?.text_align === 'string' + ? block.props.text_align + : undefined; + const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs; + const nestedChildren = Array.isArray(block?.children) + ? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean) + : []; + const withListChildren = (itemType, listType, attrs = {}) => ({ + type: listType, + attrs: { blockId }, + content: [{ + type: itemType, + attrs: { blockId, ...attrs }, + content: [ + { type: 'paragraph', attrs: { blockId }, content }, + ...nestedChildren, + ], + }], + }); + if (type === 'heading') { + const level = Number(block?.props?.level || block?.level || 1) || 1; + const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {}; + return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content }; + } + if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList'); + if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList'); + if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') { + return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) }); + } + if (type === 'quote' || type === 'blockquote') { + return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] }; + } + if (type === 'codeBlock' || type === 'code_block') { + return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content }; + } + if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } }; + if (type === 'mindmap') { + const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null; + const mindmapId = firstNonEmptyText( + block?.props?.mindmapId, + block?.props?.mindmap_id, + block?.props?.sourcePath, + block?.props?.source_path, + block?.mindmapId, + block?.mindmap_id, + data?.mindmapId, + data?.mindmap_id, + data?.id + ); + const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root'; + return { + type: 'paragraph', + attrs: withTextAlign({ + blockId, + mnoteBlockType: 'mindmap', + mindmapId, + rootNodeId, + }), + }; + } + if (type === 'media') { + const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src); + const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath); + const mediaContent = name + ? [{ + type: 'text', + text: name, + ...(sourcePath ? { marks: [{ type: 'link', attrs: { href: sourcePath } }] } : {}), + }] + : content; + return { type: 'paragraph', attrs: withTextAlign({ blockId }), content: mediaContent }; + } + if (type === 'table') { + const tableSnapshot = block?.props?.tiptapTable; + if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot; + return { + type: 'table', + content: [{ + type: 'tableRow', + content: [{ + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [{ type: 'paragraph', content }], + }], + }], + }; + } + if (type === 'toc' || type === 'tocNode' || type === 'toc_node') { + const tocSnapshot = block?.props?.tiptapTocNode || block?.props?.tiptapToc; + if (tocSnapshot && typeof tocSnapshot === 'object' && tocSnapshot.type === 'tocNode') return tocSnapshot; + return { + type: 'tocNode', + attrs: { + topOffset: Number(block?.props?.topOffset || block?.props?.top_offset || 0) || 0, + maxShowCount: Number(block?.props?.maxShowCount || block?.props?.max_show_count || 20) || 20, + showTitle: block?.props?.showTitle !== false, + }, + }; + } + if (type === 'image') { + const imageSnapshot = block?.props?.tiptapImage; + if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') return imageSnapshot; + const attrs = { + src: String(block?.props?.src || block?.src || ''), + alt: block?.props?.alt || block?.alt || null, + title: block?.props?.title || block?.title || null, + }; + return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null; + } + return { type: 'paragraph', attrs: withTextAlign({ blockId }), content }; + }; + + const textToTiptapDocument = (text) => ({ + type: 'doc', + content: [{ + type: 'paragraph', + content: text ? [{ type: 'text', text }] : [], + }], + }); + + const isTiptapDocument = (content) => ( + content && + typeof content === 'object' && + !Array.isArray(content) && + content.type === 'doc' + ); + + const mindmapDomDescriptors = (root) => { + if (!(root instanceof HTMLElement)) return []; + return Array.from(root.querySelectorAll('[data-testid="mnote-mindmap-editor-root"]')) + .flatMap((node) => { + if (!(node instanceof HTMLElement)) return []; + const mindmapId = typeof node.dataset.mnoteMindmapId === 'string' ? node.dataset.mnoteMindmapId.trim() : ''; + if (!mindmapId) return []; + const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim() + ? node.dataset.mnoteRootNodeId.trim() + : 'root'; + return [{ mindmapId, rootNodeId }]; + }); + }; + + const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => { + if (!isTiptapDocument(tiptapDocument) || !Array.isArray(tiptapDocument.content)) return tiptapDocument; + const descriptors = mindmapDomDescriptors(root); + if (!descriptors.length) return tiptapDocument; + let index = 0; + for (const node of tiptapDocument.content) { + if (node?.type !== 'paragraph' || node?.attrs?.mnoteBlockType !== 'mindmap') continue; + const descriptor = descriptors[index]; + index += 1; + if (!descriptor) continue; + node.attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; + if (typeof node.attrs.mindmapId !== 'string' || !node.attrs.mindmapId.trim()) { + node.attrs.mindmapId = descriptor.mindmapId; + } + if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) { + node.attrs.rootNodeId = descriptor.rootNodeId; + } + } + return tiptapDocument; + }; + + const toTiptapDocument = (content, fallbackText = '') => { + if (isTiptapDocument(content)) return content; + const blocks = Array.isArray(content) ? content : Array.isArray(content?.blocks) ? content.blocks : []; + const nodes = blocks.map(legacyBlockToTiptap).filter(Boolean); + if (nodes.length) return { type: 'doc', content: nodes }; + return textToTiptapDocument(fallbackText); + }; + + const decodeLocalIdSegment = (segment) => { + const encoded = String(segment || '').replace(/~([0-9a-fA-F]{2})/g, '%$1'); + try { + return decodeURIComponent(encoded); + } catch (_) { + return String(segment || '').replace(/~2F/g, '/').replace(/~20/g, ' '); + } + }; + + const localMarkdownRelativePathFromDocumentId = (documentId) => { + const raw = String(documentId || '').trim(); + const segment = raw.startsWith('local-md:') ? raw.slice('local-md:'.length) : raw; + return decodeLocalIdSegment(segment).replace(/^\/+/, ''); + }; + + const localMarkdownDocumentIdFromRelativePath = (relativePath) => { + const normalized = String(relativePath || '').trim().replace(/^\/+/, ''); + if (!normalized) return ''; + return 'local-md:' + normalized + .split('/') + .map((part) => part.replace(/ /g, '~20')) + .join('~2F'); + }; + + const localMarkdownDirectoryFromDocumentId = (documentId) => { + const relativePath = localMarkdownRelativePathFromDocumentId(documentId); + const slash = relativePath.lastIndexOf('/'); + return slash >= 0 ? relativePath.slice(0, slash) : ''; + }; + + const isExternalOrSpecialUrl = (value) => { + const text = String(value || '').trim(); + return !text + || text.startsWith('#') + || text.startsWith('data:') + || text.startsWith('blob:') + || text.startsWith('mailto:') + || text.startsWith('http://') + || text.startsWith('https://') + || text.startsWith('/api/'); + }; + + const normalizeLocalAssetRelativePath = (value, context) => { + const text = String(value || '').trim(); + if (!text || isExternalOrSpecialUrl(text)) return text; + if (text.startsWith('/')) return text.replace(/^\/+/, ''); + const baseDir = localMarkdownDirectoryFromDocumentId(context?.documentId); + return (baseDir ? `${baseDir}/${text}` : text) + .split('/') + .filter((part) => part && part !== '.') + .join('/'); + }; + + const localFileOpenUrlForTiptap = (value, context) => { + if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value; + const relativePath = normalizeLocalAssetRelativePath(value, context); + if (!relativePath || isExternalOrSpecialUrl(relativePath)) return value; + const url = new URL('/api/local-folder/files/open', window.location.origin); + url.searchParams.set('rootUri', context.rootUri); + url.searchParams.set('path', relativePath); + return url.toString(); + }; + + const localizeTiptapAssetUrls = (node, context) => { + if (!node || typeof node !== 'object') return node; + if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { + node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) }; + } + if (Array.isArray(node.marks)) { + node.marks = node.marks.map((mark) => { + if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; + return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } }; + }); + } + if (Array.isArray(node.content)) { + node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context)); + } + return node; + }; + + const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => { + if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) { + return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context); + } + const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; + if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context); + return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context); + }; + + const inlineTextNodes = (node) => { + if (!node || typeof node !== 'object') return []; + if (Array.isArray(node.content)) { + return node.content.flatMap((child) => { + if (child?.type === 'text') { + const text = typeof child.text === 'string' ? child.text : ''; + if (!text) return []; + const styles = {}; + const marks = []; + for (const mark of Array.isArray(child.marks) ? child.marks : []) { + if (mark?.type === 'bold') { + styles.bold = true; + marks.push('bold'); + } + if (mark?.type === 'italic') { + styles.italic = true; + marks.push('italic'); + } + if (mark?.type === 'underline') { + styles.underline = true; + marks.push('underline'); + } + if (mark?.type === 'strike') { + styles.strike = true; + marks.push('strike'); + } + if (mark?.type === 'code') { + styles.code = true; + marks.push('code'); + } + if (mark?.type === 'link') { + const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; + if (href) styles.link = href; + } + } + return [{ + payload: { type: 'text', text, ...(marks.length ? { marks } : {}) }, + attrs: Object.keys(styles).length ? { styles } : {}, + type: 'text', + text, + ...(Object.keys(styles).length ? { styles } : {}), + }]; + } + if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }]; + return inlineTextNodes(child); + }); + } + return []; + }; + + const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null; + const blockIdOf = (node, index) => { + const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : ''; + return raw || `block-${index + 1}`; + }; + const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => { + const data = attrs?.data && typeof attrs.data === 'object' ? attrs.data : {}; + const mindmapId = firstNonEmptyText( + attrs?.mindmapId, + attrs?.mindmap_id, + attrs?.sourcePath, + attrs?.source_path, + data?.mindmapId, + data?.mindmap_id, + data?.id, + fallbackMindmapId + ); + const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root'; + const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version); + return { + mindmapId, + rootNodeId, + ...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}), + }; + }; + + const tiptapNodeToEditorBlock = (node, index) => { + const blockId = blockIdOf(node, index); + if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') { + const mindmapId = firstNonEmptyText( + node?.attrs?.mindmapId, + node?.attrs?.mindmap_id, + node?.attrs?.sourcePath, + node?.attrs?.source_path, + blockId + ); + return { + blockId, + blockType: 'mindmap', + props: { + ...mindmapPropsFromAttrs(node?.attrs, blockId), + sourcePath: mindmapId, + }, + contentNodes: [], + childBlockIds: [], + }; + } + if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + if (node?.type === 'heading') { + const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1)); + return { blockId, blockType: 'heading', props: { headingLevel: level }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + } + if (node?.type === 'bulletList') return { blockId, blockType: 'bullet_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] }; + if (node?.type === 'orderedList') return { blockId, blockType: 'numbered_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] }; + if (node?.type === 'taskList') { + const taskItem = firstChild(node); + return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] }; + } + if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] }; + if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + return null; + }; + + const editorDocumentFromTiptapDocument = (bootstrap, tiptapDocument) => { + const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : []; + const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean); + return { + documentId: bootstrap.documentId, + rootBlockIds: blocks.map((block) => block.blockId), + blocks, + }; + }; + + const legacyBlocksFromEditorDocument = (editorDocument) => ( + Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : [] + ).map((block) => ({ + id: block.blockId, + type: block.blockType, + props: block.blockType === 'heading' + ? { level: block.props?.headingLevel || 1 } + : block.blockType === 'todo' + ? { checked: Boolean(block.props?.checked) } + : block.blockType === 'code_block' + ? { language: block.props?.language || null } + : block.blockType === 'mindmap' + ? mindmapPropsFromAttrs(block.props || {}, block.blockId) + : block.blockType === 'image' + ? { ...(block.props || {}) } + : block.blockType === 'toc' + ? { ...(block.props || {}) } + : block.blockType === 'table' + ? { ...(block.props || {}) } + : undefined, + content: block.blockType === 'mindmap' + ? '' + : Array.isArray(block.contentNodes) + ? block.contentNodes.map((node) => { + if (!node || typeof node !== 'object') return null; + const payload = node.payload && typeof node.payload === 'object' ? node.payload : {}; + const text = typeof payload.text === 'string' + ? payload.text + : payload.type === 'hard_break' + ? '\n' + : typeof node.text === 'string' + ? node.text + : ''; + if (!text) return null; + const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; + const styles = attrs.styles && typeof attrs.styles === 'object' + ? attrs.styles + : node.styles && typeof node.styles === 'object' + ? node.styles + : null; + const marks = Array.isArray(payload.marks) ? payload.marks : []; + return { + type: 'text', + text, + ...(styles ? { styles } : {}), + ...(marks.length ? { marks } : {}), + }; + }).filter(Boolean) + : '', + })); + + const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string' + ? body.conflictDetectionKey + : typeof body?.conflict_detection_key === 'string' + ? body.conflict_detection_key + : typeof body?.fileVersion === 'string' + ? body.fileVersion + : typeof body?.file_version === 'string' + ? body.file_version + : null; + + const conflictDetectionKeyBelongsToSession = (session, key) => { + if (!session || session.sourceKind !== 'local_folder') return true; + const value = String(key || '').trim(); + if (!value) return false; + return value.startsWith(`local-md:${session.documentId}:`); + }; + + const revisionFromConflictKey = (value) => { + const match = String(value || '').match(/:(\d+)$/); + return match ? Number(match[1]) : null; + }; + + const normalizeBridgeValue = (value) => { + if (value instanceof Map) { + const out = {}; + for (const [key, item] of value.entries()) out[key] = normalizeBridgeValue(item); + return out; + } + if (Array.isArray(value)) return value.map(normalizeBridgeValue); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBridgeValue(item)])); + } + return value; + }; + + const normalizeEnvelopePayload = (event) => { + const detail = normalizeBridgeValue(event?.detail); + if (!detail || typeof detail !== 'object') return null; + const payload = detail.payload && typeof detail.payload === 'object' ? detail.payload : detail; + return payload && typeof payload === 'object' ? payload : null; + }; + + const setStatus = (runtime, status, message) => { + runtime.root.setAttribute('data-runtime-editor-status', status); + if (message) { + runtime.root.setAttribute('data-runtime-editor-error', message); + } else { + runtime.root.removeAttribute('data-runtime-editor-error'); + } + if (runtime.observability instanceof HTMLElement) { + runtime.observability.setAttribute('data-editor-host-status', status); + runtime.observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island'); + } + }; + + const pageAggregateUrl = (bootstrap) => { + const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin); + url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder'); + if (bootstrap.workspaceId) url.searchParams.set('workspaceId', bootstrap.workspaceId); + if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri); + return url; + }; + const pageAggregateUrlFromDescriptor = (descriptor) => { + const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); + url.searchParams.set('sourceKind', descriptor.sourceKind || 'convex_workspace'); + if (descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); + if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); + return url; + }; + const buildBootstrapFromAggregate = (aggregate, descriptor, paneRole) => ({ + schema: 'mnote.editor_bootstrap.v1', + documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId, + workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '', + paneRole, + sourceKind: descriptor.sourceKind || 'convex_workspace', + rootUri: descriptor.rootUri || '', + pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__', + saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder' + ? '/api/page-body/write' + : '/api/documents/save', + titleEndpoint: '/api/documents/title', + editorHostKind: 'leptos_tiptap_island', + }); + const syncPageAggregateScript = (session, aggregate) => { + if (!session || !aggregate) return; + const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__'; + const node = document.getElementById(scriptId); + if (!node) return; + try { + node.textContent = JSON.stringify(aggregate); + node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now())); + } catch (error) { + console.warn('mnote Page Aggregate script 同步失败', error); + } + }; + + const documentSessionRegistry = new Map(); + const localFolderEventRegistry = new Map(); + const paneViewRegistry = new Map(); + const mindmapPaneViewRegistry = new Map(); + const resourceTabRegistry = new Map(); + const resourceTabMru = { primary: [], secondary: [] }; + const resourceTabMruMax = 20; + const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded'; + let nextViewId = 1; + const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; + const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; + const SESSION_RELEASE_DELAY_MS = 1200; + const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1'; + + const ensureLocalFolderSelfChangeSuppressions = () => { + const now = Date.now(); + const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map + ? window.__mnoteLocalFolderSelfChangeSuppressions + : new Map(); + window.__mnoteLocalFolderSelfChangeSuppressions = map; + try { + const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : ''; + const parsed = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed === 'object') { + Object.entries(parsed).forEach(([documentId, expiresAt]) => { + const doc = String(documentId || '').trim(); + const expiry = Number(expiresAt || 0); + if (doc && Number.isFinite(expiry) && expiry > now) { + map.set(doc, expiry); + } else if (doc) { + map.delete(doc); + delete parsed[documentId]; + } + }); + if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed)); + } + } catch (_) {} + return map; + }; + + const unmountMindmapPane = (paneRole) => { + const view = mindmapPaneViewRegistry.get(paneRole); + if (!view) return; + mindmapPaneViewRegistry.delete(paneRole); + if (view.mountId != null && view.runtime && typeof view.runtime.unmount === 'function') { + try { + view.runtime.unmount(view.mountId); + } catch (error) { + console.warn('mnote mindmap pane unmount failed', error); + } + } + if (view.root instanceof HTMLElement) { + view.root.removeAttribute('data-runtime-mount-id'); + view.root.removeAttribute('data-mnote-object-editor'); + view.root.removeAttribute('data-mnote-object-identity'); + view.root.removeAttribute('data-mnote-mindmap-id'); + view.root.replaceChildren(); + } + }; + + const parseLocalFolderEventPayload = (event) => { + try { + return JSON.parse(String(event?.data || '{}')); + } catch (_) { + return null; + } + }; + + const shouldSuppressLocalFolderSelfChange = (documentId, eventKind) => { + const doc = String(documentId || '').trim(); + if (!doc) return false; + const kind = String(eventKind || ''); + const suppressibleSelfWrite = kind.includes('Create') + || kind.includes('Metadata') + || kind.includes('Modify(Data') + || kind.includes('Modify(Any') + || kind.includes('Modify(Name'); + if (!suppressibleSelfWrite) return false; + const suppressions = ensureLocalFolderSelfChangeSuppressions(); + if (!suppressions || typeof suppressions.get !== 'function') return false; + const expiresAt = Number(suppressions.get(doc) || 0); + if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false; + if (Date.now() > expiresAt) { + if (typeof suppressions.delete === 'function') suppressions.delete(doc); + return false; + } + return true; + }; + + const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => { + if (!session || session.sourceKind !== 'local_folder') return; + const doc = String(session.documentId || '').trim(); + if (!doc) return; + const suppressions = ensureLocalFolderSelfChangeSuppressions(); + if (!suppressions || typeof suppressions.set !== 'function') return; + const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000); + suppressions.set(doc, expiresAt); + try { + if (!window.sessionStorage) return; + const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const next = parsed && typeof parsed === 'object' ? parsed : {}; + next[doc] = expiresAt; + window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next)); + } catch (_) {} + }; + + const normalizeSessionSourceKind = (bootstrap) => { + const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; + return value || 'convex_workspace'; + }; + + const buildDocumentSessionKey = (bootstrap) => { + const sourceKind = normalizeSessionSourceKind(bootstrap); + const scope = sourceKind === 'local_folder' + ? String(bootstrap?.rootUri || '').trim() + : String(bootstrap?.workspaceId || '').trim(); + return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`; + }; + + const sessionViews = (session) => Array.from(session.views.values()); + + const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`; + + const detachSessionFromLocalFolderChannel = (session) => { + const channel = session.localFolderChannel; + if (!channel) return; + channel.sessions.delete(session.key); + if (channel.sessions.size === 0) { + try { + channel.eventSource.close(); + } catch (_) { + // noop + } + localFolderEventRegistry.delete(channel.key); + } + session.localFolderChannel = null; + }; + + const releaseDocumentSession = (session) => { + if (session.saveTimer) { + window.clearTimeout(session.saveTimer); + session.saveTimer = 0; + } + if (session.externalRefreshTimer) { + window.clearTimeout(session.externalRefreshTimer); + session.externalRefreshTimer = 0; + } + if (session.releaseTimer) { + window.clearTimeout(session.releaseTimer); + session.releaseTimer = 0; + } + detachSessionFromLocalFolderChannel(session); + if (documentSessionRegistry.get(session.key) === session) { + documentSessionRegistry.delete(session.key); + } + }; + + const scheduleDocumentSessionRelease = (session) => { + if (session.views.size > 0) return; + if (session.releaseTimer) window.clearTimeout(session.releaseTimer); + session.releaseTimer = window.setTimeout(() => { + session.releaseTimer = 0; + if (session.views.size === 0) { + releaseDocumentSession(session); + } + }, SESSION_RELEASE_DELAY_MS); + }; + + const cancelDocumentSessionRelease = (session) => { + if (!session.releaseTimer) return; + window.clearTimeout(session.releaseTimer); + session.releaseTimer = 0; + }; + + window.__mnoteDebugDocumentSessions = { + snapshot: () => ({ + sessionCount: documentSessionRegistry.size, + sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({ + key, + documentId: session.documentId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + workspaceId: session.workspaceId, + viewCount: session.views.size, + status: session.status, + conflictDetectionKey: session.conflictDetectionKey || '', + lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '', + lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null, + dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), + })), + localFolderChannelCount: localFolderEventRegistry.size, + localFolderRoots: Array.from(localFolderEventRegistry.keys()), + }), + }; + + const currentEditorText = (view) => { + const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); + return editor?.textContent || ''; + }; + + const findScrollableEditorContainer = (view) => { + let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); + while (current instanceof HTMLElement) { + if (current.scrollHeight > current.clientHeight + 8) { + return current; + } + current = current.parentElement; + } + return null; + }; + + const preserveViewScrollPosition = (view, operation) => { + const scrollable = findScrollableEditorContainer(view); + const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null; + const viewportTop = window.scrollY; + const viewportLeft = window.scrollX; + operation(); + const restore = () => { + const current = findScrollableEditorContainer(view); + if (current instanceof HTMLElement && scrollTop != null) { + current.scrollTop = scrollTop; + } + if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) { + window.scrollTo({ + top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY, + left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX, + behavior: 'auto', + }); + } + }; + window.requestAnimationFrame(() => { + restore(); + window.setTimeout(restore, 0); + window.setTimeout(restore, 80); + window.setTimeout(restore, 180); + window.setTimeout(restore, 320); + }); + }; + + const dispatchRuntimeCommand = (view, payload, source) => { + view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(COMMAND_EVENT, { + bubbles: true, + detail: { + protocol: BRIDGE_PROTOCOL, + runtime: 'mnote-leptos-tiptap-spike', + version: '1.1.0', + source: source || 'mnote-web-document-session', + event: COMMAND_EVENT, + payload, + }, + })); + }; + + const clearEmbeddedLocalDraft = (runtimeDescriptor) => { + if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return; + try { + const storage = window.localStorage; + const base = 'mnote.leptos-tiptap-spike.document'; + const keys = [ + `${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`, + `${base}:${runtimeDescriptor.bootstrap.documentId}`, + ]; + for (const key of keys) storage.removeItem(key); + } catch (error) { + console.warn('mnote local folder 草稿清理失败', error); + } + }; + + const setSessionStatus = (session, status, message) => { + session.status = status; + session.error = message || null; + sessionViews(session).forEach((view) => { + setStatus(view.runtimeDescriptor, status, message); + }); + }; + + const dispatchSessionContentToView = (session, view, source) => { + view.suppressedSerialized = session.currentSerialized; + view.lastKnownSerialized = session.currentSerialized; + preserveViewScrollPosition(view, () => { + dispatchRuntimeCommand(view, { + command: 'replaceContent', + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + }, source || 'mnote-web-document-session-sync'); + }); + }; + + const dispatchSessionMetaToView = (session, view, source) => { + dispatchRuntimeCommand(view, { + command: 'replaceContent', + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + }, source || 'mnote-web-document-session-meta'); + }; + + const syncSessionMetaToViews = (session) => { + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionMetaToView(session, view); + }); + }; + + const broadcastSessionContent = (session, sourceView) => { + sessionViews(session).forEach((view) => { + if (sourceView && view.id === sourceView.id) return; + if (view.mountId == null) return; + dispatchSessionContentToView(session, view); + }); + }; + + const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + + const sessionPlainText = (session) => { + const liveText = sessionViews(session) + .map((view) => normalizePlainText(currentEditorText(view))) + .find((text) => text); + if (liveText) return liveText; + return normalizePlainText(flattenText(session.currentTiptapDocument)); + }; + + const sessionHasRecentExternalSignal = (session) => ( + session.sourceKind === 'local_folder' + && Number.isFinite(session.lastExternalChangeSignalAt) + && session.lastExternalChangeSignalAt > 0 + && (Date.now() - session.lastExternalChangeSignalAt) < 1500 + ); + + const sessionHasRecentLocalInput = (session) => ( + Number.isFinite(session.lastUserInputAt) + && session.lastUserInputAt > 0 + && (Date.now() - session.lastUserInputAt) < 1500 + ); + + const fetchLatestSessionAggregate = async (session) => { + const response = await fetch(pageAggregateUrl({ + documentId: session.documentId, + sourceKind: session.sourceKind, + workspaceId: session.workspaceId, + rootUri: session.rootUri, + }).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status); + const payload = await response.json(); + const nextAggregate = payload?.result; + if (!nextAggregate || typeof nextAggregate !== 'object') { + throw new Error('conflict_latest_missing_aggregate'); + } + return nextAggregate; + }; + + const fetchLatestResourceSnapshot = async (session) => { + const url = new URL('/api/local-folder/resource/read', window.location.origin); + url.searchParams.set('rootUri', session.rootUri || ''); + url.searchParams.set('path', session.resourcePath || ''); + const response = await fetch(url.toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status); + const nextResource = payload.result; + if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot'); + return nextResource; + }; + + const aggregatePlainText = (aggregate) => { + const body = aggregate?.body || {}; + return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim(); + }; + + const resourceSnapshotPlainText = (snapshot) => ( + String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '') + .replace(/\n{3,}/g, '\n\n') + .trim() + ); + + const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => { + if (!session || session.sourceKind !== 'local_folder') return false; + if (!sessionHasRecentExternalSignal(session)) return false; + if (!sessionPlainText(session)) return false; + return !aggregatePlainText(nextAggregate); + }; + + const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + + const conflictEnvelopeFromResponse = (payload) => ( + payload?.error?.details?.conflict + || payload?.details?.conflict + || null + ); + + const conflictSourceLabel = (session) => { + if (!session) return ''; + if (session.lastExternalWriteSource === 'mnote-hermes-tool') { + const runId = String(session.lastExternalWriteRunId || '').trim(); + return runId ? `agent run ${runId}` : 'agent run'; + } + return '本地文件变更'; + }; + + const conflictMessageFromEnvelope = (session, envelope) => { + const baseMessage = envelope?.message || externalConflictMessage; + const sourceLabel = conflictSourceLabel(session); + return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage; + }; + + const ensureSessionConflictEnvelope = (session, message) => { + if (!session) return null; + if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope; + const envelope = { + code: 'local_markdown_external_change', + documentId: session.documentId, + rootUri: session.rootUri || null, + currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null, + editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null, + externalActor: session.lastExternalWriteSource || null, + dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), + bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null, + message: message || externalConflictMessage, + suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'], + }; + session.lastExternalConflictEnvelope = envelope; + return envelope; + }; + + const clearSessionConflictSurface = (session) => { + var _rt_ = window.__mnoteDocumentConflictPanelRuntime; + if (_rt_ && typeof _rt_.clearSessionConflictSurface === 'function') { + _rt_.clearSessionConflictSurface(session, { sessionViews: sessionViews }); + return; + } + sessionViews(session).forEach((view) => { + const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; + if (!(host instanceof HTMLElement)) return; + host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove()); + }); + }; + + const applyAggregateSnapshotToSession = (session, nextAggregate, source) => { + const nextBody = nextAggregate?.body || {}; + const nextPermissions = nextAggregate?.head?.permissions || {}; + const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); + session.latestAggregate = nextAggregate; + syncPageAggregateScript(session, nextAggregate); + session.title = nextAggregate?.head?.title || session.title; + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + session.revision = nextRevision; + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey || ''; + session.readOnly = Boolean(nextPermissions.readOnly); + session.dirty = false; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved'); + }); + setSessionStatus(session, 'synced-external-change'); + }; + + const applyResourceSnapshotToSession = (session, nextResource, source) => { + const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); + const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); + const nextSerialized = JSON.stringify(nextTiptapDocument); + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + if (nextConflictKey) { + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey; + session.fileVersion = nextConflictKey; + } + session.dirty = false; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved'); + }); + setSessionStatus(session, 'synced-external-change'); + }; + + const openConflictDiffPanel = async (session, panel) => { + const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]'); + if (!(diffPanel instanceof HTMLElement)) return; + diffPanel.hidden = false; + diffPanel.replaceChildren(); + const loading = document.createElement('div'); + loading.className = 'mnote-conflict-diff-status'; + loading.textContent = '正在读取磁盘版本...'; + diffPanel.appendChild(loading); + try { + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const latestText = session.sessionKind === 'resource' + ? resourceSnapshotPlainText(latest) + : aggregatePlainText(latest); + diffPanel.replaceChildren(); + var _dpRT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_dpRT_ && typeof _dpRT_.populateSessionConflictDiffPanel === 'function') { + _dpRT_.populateSessionConflictDiffPanel(diffPanel, { + currentText: sessionPlainText(session) || '(当前编辑器为空)', + diskText: latestText || '(磁盘版本为空)', + currentMergeText: sessionPlainText(session) || '', + diskMergeText: latestText || '', + mergeDefaultText: sessionPlainText(session) || latestText || '', + }, { + onUseCurrent: function() { + return sessionPlainText(session) || ''; + }, + onUseDisk: function() { + return latestText || ''; + }, + onSaveMerge: function(mergeText) { + writeMergedConflictResult(session, panel, mergeText).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }, + }); + } else { + const current = document.createElement('pre'); + current.setAttribute('data-testid', 'mnote-conflict-current-text'); + current.textContent = sessionPlainText(session) || '(当前编辑器为空)'; + const disk = document.createElement('pre'); + disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); + disk.textContent = latestText || '(磁盘版本为空)'; + const currentTitle = document.createElement('h3'); + currentTitle.textContent = '当前编辑器版本'; + const diskTitle = document.createElement('h3'); + diskTitle.textContent = '磁盘版本'; + const currentBox = document.createElement('section'); + currentBox.append(currentTitle, current); + const diskBox = document.createElement('section'); + diskBox.append(diskTitle, disk); + const mergeTitle = document.createElement('h3'); + mergeTitle.textContent = '合并结果'; + const mergeText = document.createElement('textarea'); + mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); + mergeText.value = sessionPlainText(session) || latestText || ''; + const mergeActions = document.createElement('div'); + mergeActions.className = 'mnote-conflict-actions'; + const useCurrent = document.createElement('button'); + useCurrent.type = 'button'; + useCurrent.textContent = '使用当前版本'; + useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current'); + const useDisk = document.createElement('button'); + useDisk.type = 'button'; + useDisk.textContent = '使用磁盘版本'; + useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk'); + const saveMerge = document.createElement('button'); + saveMerge.type = 'button'; + saveMerge.textContent = '写回合并结果'; + saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save'); + mergeActions.append(useCurrent, useDisk, saveMerge); + const mergeBox = document.createElement('section'); + mergeBox.className = 'mnote-conflict-merge-box'; + mergeBox.append(mergeTitle, mergeText, mergeActions); + diffPanel.append(currentBox, diskBox, mergeBox); + useCurrent.addEventListener('click', () => { + mergeText.value = sessionPlainText(session) || ''; + }); + useDisk.addEventListener('click', () => { + mergeText.value = latestText || ''; + }); + saveMerge.addEventListener('click', () => { + writeMergedConflictResult(session, panel, mergeText.value).catch((error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }); + }); + } + } catch (error) { + loading.textContent = error instanceof Error ? error.message : String(error); + diffPanel.replaceChildren(loading); + } + }; + + const acceptDiskVersion = async (session) => { + var _RT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') { + return _RT_.acceptDiskVersion(session, { + setSessionStatus: setSessionStatus, + fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, + applyResourceSnapshotToSession: applyResourceSnapshotToSession, + fetchLatestSessionAggregate: fetchLatestSessionAggregate, + applyAggregateSnapshotToSession: applyAggregateSnapshotToSession, + }); + } + // inline fallback + setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); + if (session.sessionKind === 'resource') { + const latestResource = await fetchLatestResourceSnapshot(session); + applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk'); + return; + } + const latest = await fetchLatestSessionAggregate(session); + applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); + }; + + const keepCurrentEditorVersion = async (session) => { + var _RT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') { + return _RT_.keepCurrentEditorVersion(session, { + setSessionStatus: setSessionStatus, + fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, + fetchLatestSessionAggregate: fetchLatestSessionAggregate, + sessionViews: sessionViews, + currentEditorText: currentEditorText, + normalizePlainText: normalizePlainText, + hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom, + textToTiptapDocument: textToTiptapDocument, + conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, + clearSessionConflictSurface: clearSessionConflictSurface, + persistSession: persistSession, + }); + } + // inline fallback + setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; + if (hydrateView) { + const liveText = normalizePlainText(currentEditorText(hydrateView)); + if (liveText) { + session.currentTiptapDocument = hydrateMindmapAttrsFromDom( + textToTiptapDocument(liveText), + hydrateView.runtimeDescriptor.root, + ); + } + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + } + const nextKey = session.sessionKind === 'resource' + ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() + : conflictDetectionKeyFromBody(latest.body || {}); + if (nextKey) { + session.conflictDetectionKey = nextKey; + session.lastExternalConflictDetectionKey = nextKey; + } + session.hasExternalConflict = false; + session.externalChangePending = false; + session.saving = false; + session.dirty = true; + clearSessionConflictSurface(session); + await persistSession(session); + }; + + const writeMergedConflictResult = async (session, panel, mergedText) => { + var _RT_ = window.__mnoteDocumentConflictPanelRuntime; + if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') { + return _RT_.writeMergedConflictResult(session, panel, mergedText, { + setSessionStatus: setSessionStatus, + fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, + fetchLatestSessionAggregate: fetchLatestSessionAggregate, + conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, + textToTiptapDocument: textToTiptapDocument, + persistSession: persistSession, + }); + } + // inline fallback + setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const nextKey = session.sessionKind === 'resource' + ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() + : conflictDetectionKeyFromBody(latest.body || {}); + if (nextKey) { + session.conflictDetectionKey = nextKey; + session.lastExternalConflictDetectionKey = nextKey; + } + session.currentTiptapDocument = textToTiptapDocument(String(mergedText || '')); + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + session.hasExternalConflict = false; + session.externalChangePending = false; + session.saving = false; + session.dirty = true; + if (panel && typeof panel.remove === 'function') panel.remove(); + await persistSession(session); + }; + + const runSessionConflictAction = (action, onError) => { + const runtime = window.__mnoteDocumentConflictPanelRuntime; + if (runtime && typeof runtime.runSessionConflictAction === 'function') { + runtime.runSessionConflictAction(action, { onError }); + return; + } + try { + const result = action(); + if (result && typeof result.then === 'function') result.catch(onError); + } catch (error) { + onError(error); + } + }; + + const renderSessionConflictSurface = (session, message) => { + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + const runtime = window.__mnoteDocumentConflictPanelRuntime; + const handleConflictActionError = (error) => { + setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); + renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); + }; + let panel = null; + if (runtime && typeof runtime.createSessionConflictPanel === 'function') { + panel = runtime.createSessionConflictPanel(session, message, { + externalConflictMessage, + conflictSourceLabel, + onAcceptDisk: () => { + runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); + }, + onKeepCurrent: () => { + runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); + }, + onOpenDiff: (_session, createdPanel) => { + openConflictDiffPanel(session, createdPanel); + }, + }); + } + if (!(panel instanceof HTMLElement)) { + panel = document.createElement('section'); + panel.className = 'mnote-editor-conflict-panel'; + panel.setAttribute('data-testid', 'mnote-editor-conflict-panel'); + panel.setAttribute('role', 'status'); + panel.setAttribute('aria-live', 'polite'); + + const heading = document.createElement('h2'); + heading.textContent = '文件冲突'; + const text = document.createElement('p'); + text.textContent = message || externalConflictMessage; + const meta = document.createElement('div'); + meta.className = 'mnote-conflict-meta'; + const fileLabel = session.sessionKind === 'resource' + ? `${session.rootUri || ''}/${session.resourcePath || session.documentId}` + : (session.rootUri || session.documentId); + meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; + const actions = document.createElement('div'); + actions.className = 'mnote-conflict-actions'; + const acceptDisk = document.createElement('button'); + acceptDisk.type = 'button'; + acceptDisk.textContent = '接受磁盘版本'; + acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk'); + const keepCurrent = document.createElement('button'); + keepCurrent.type = 'button'; + keepCurrent.textContent = '保留当前编辑器版本'; + keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current'); + const openDiff = document.createElement('button'); + openDiff.type = 'button'; + openDiff.textContent = '打开 diff'; + openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff'); + actions.append(acceptDisk, keepCurrent, openDiff); + const diffPanel = document.createElement('div'); + diffPanel.className = 'mnote-conflict-diff-panel'; + diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel'); + diffPanel.hidden = true; + panel.append(heading, text, meta, actions, diffPanel); + + acceptDisk.addEventListener('click', () => { + runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); + }); + keepCurrent.addEventListener('click', () => { + runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); + }); + openDiff.addEventListener('click', () => { + openConflictDiffPanel(session, panel); + }); + } + + if (runtime && typeof runtime.mountSessionConflictPanel === 'function') { + if (runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)) { + return; + } + } + const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; + if (!(host instanceof HTMLElement)) return; + const header = host.querySelector('.document-shell-header'); + if (header && header.parentNode) { + header.parentNode.insertBefore(panel, header.nextSibling); + } else { + host.prepend(panel); + } + }); + }; + + const markSessionExternalConflict = (session, message) => { + session.externalChangePending = false; + ensureSessionConflictEnvelope(session, message); + session.hasExternalConflict = true; + if (session.saveTimer) { + window.clearTimeout(session.saveTimer); + session.saveTimer = 0; + } + const nextMessage = message || externalConflictMessage; + setSessionStatus(session, 'external-change-conflict', nextMessage); + renderSessionConflictSurface(session, nextMessage); + }; + + const queueSessionSave = (session) => { + if (session.readOnly || session.hasExternalConflict) return; + if (session.saveTimer) window.clearTimeout(session.saveTimer); + setSessionStatus(session, 'dirty'); + session.saveTimer = window.setTimeout(() => { + session.saveTimer = 0; + void persistSession(session); + }, 650); + }; + + const persistSession = async (session) => { + if (session.readOnly || session.saving || session.hasExternalConflict) return; + const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; + if (hydrateView) { + hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root); + session.currentSerialized = JSON.stringify(session.currentTiptapDocument); + } + const serialized = session.currentSerialized; + if (!session.dirty && serialized === session.lastPersistedSerialized) { + setSessionStatus(session, 'saved'); + return; + } + session.saving = true; + setSessionStatus(session, 'saving'); + try { + const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); + const content = legacyBlocksFromEditorDocument(editorDocument); + const saveEndpoint = session.saveEndpoint || '/api/documents/save'; + const savePayload = session.sessionKind === 'resource' + ? { + rootUri: session.rootUri, + path: session.resourcePath, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap-resource-tab', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + } + : { + documentId: session.documentId, + workspaceId: session.workspaceId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + revision: session.revision, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + }; + if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') { + savePayload.conflictDetectionKey = session.conflictDetectionKey; + } + markLocalFolderSelfChangeSuppression(session); + const response = await fetch(saveEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(savePayload), + }); + const result = await response.json().catch(() => null); + if (!response.ok || !result || result.ok !== true) { + const message = result?.error?.message || result?.message || `save_failed_${response.status}`; + const conflictEnvelope = conflictEnvelopeFromResponse(result); + if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') { + session.saving = false; + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } + throw new Error(message); + } + const saved = result.result || {}; + if (Number.isInteger(saved.revision)) session.revision = saved.revision; + if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) { + session.conflictDetectionKey = saved.conflict_detection_key.trim(); + } + if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) { + session.conflictDetectionKey = saved.conflictDetectionKey.trim(); + } + if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) { + session.conflictDetectionKey = saved.fileVersion.trim(); + } + if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + syncSessionMetaToViews(session); + session.lastPersistedSerialized = serialized; + session.saving = false; + if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') { + const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0]; + if (primaryView) { + const plainText = sessionPlainText(session); + window.__mnoteRecordPageHistorySnapshot('save', { + wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length, + characterCount: plainText.replace(/\s/g, '').length, + blockCount: editorDocument.blocks.length, + todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length, + todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length, + }); + } + } + if (session.currentSerialized === serialized) { + session.dirty = false; + setSessionStatus(session, 'saved'); + } else { + session.dirty = true; + setSessionStatus(session, 'dirty'); + queueSessionSave(session); + } + syncResourceSessionTabGuards(session); + } catch (error) { + session.saving = false; + setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error)); + syncResourceSessionTabGuards(session); + } + }; + + const scheduleSessionExternalRefresh = (session, source) => { + if (!session || session.views.size === 0) return; + if (session.externalRefreshTimer) return; + session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change'; + session.externalRefreshTimer = window.setTimeout(() => { + const refreshSource = session.externalRefreshSource || 'mnote-web-external-change'; + session.externalRefreshSource = ''; + session.externalRefreshTimer = 0; + void refreshSessionFromExternalChange(session, refreshSource); + }, 120); + }; + + const refreshSessionFromExternalChange = async (session, source) => { + if (document.hidden) return; + if (!session || session.views.size === 0) return; + if (session.sourceKind === 'local_folder' && !session.rootUri) return; + try { + const response = await fetch(pageAggregateUrl({ + documentId: session.documentId, + sourceKind: session.sourceKind, + workspaceId: session.workspaceId, + rootUri: session.rootUri, + }).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + if (session.sourceKind === 'local_folder') { + markSessionExternalConflict(session, externalConflictMessage); + } + return; + } + const payload = await response.json(); + const conflictEnvelope = conflictEnvelopeFromResponse(payload); + if (response.status === 409 && conflictEnvelope) { + session.lastExternalConflictEnvelope = conflictEnvelope; + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); + return; + } + let nextAggregate = payload?.result; + if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) { + await delay(500); + try { + const retryAggregate = await fetchLatestSessionAggregate(session); + if (aggregatePlainText(retryAggregate)) { + nextAggregate = retryAggregate; + } else { + markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。'); + return; + } + } catch (_retryError) { + markSessionExternalConflict(session, externalConflictMessage); + return; + } + } + const nextBody = nextAggregate?.body || {}; + const nextPermissions = nextAggregate?.head?.permissions || {}; + const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const contentChanged = nextSerialized !== session.currentSerialized; + session.externalChangePending = false; + if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { + session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; + if (!contentChanged) return; + } + if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, externalConflictMessage); + return; + } + const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); + session.latestAggregate = nextAggregate; + syncPageAggregateScript(session, nextAggregate); + session.title = nextAggregate?.head?.title || session.title; + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = session.currentSerialized; + session.revision = nextRevision; + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey || ''; + session.readOnly = Boolean(nextPermissions.readOnly); + session.dirty = false; + session.hasExternalConflict = false; + session.lastUserInputAt = 0; + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change'); + }); + setSessionStatus(session, 'synced-external-change'); + } catch (error) { + console.warn('mnote 页面外部更新检测失败', error); + } + }; + + const refreshSessionFromExternalFileChange = async (session) => { + if (session.sourceKind !== 'local_folder') return; + await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); + }; + + const ensureLocalFolderEventChannel = (session) => { + if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') { + return; + } + const channelKey = localFolderEventChannelKey(session); + let channel = localFolderEventRegistry.get(channelKey); + if (!channel) { + const url = new URL('/api/local-folder/events', window.location.origin); + url.searchParams.set('rootUri', session.rootUri); + url.searchParams.set('documentId', session.documentId); + const eventSource = new EventSource(url.toString()); + channel = { + key: channelKey, + rootUri: session.rootUri, + documentId: session.documentId, + eventSource, + sessions: new Map(), + }; + eventSource.addEventListener('change', (event) => { + const payload = parseLocalFolderEventPayload(event); + if (!payload) return; + Array.from(channel.sessions.values()).forEach((targetSession) => { + if (!targetSession || targetSession.views.size === 0) return; + const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; + if (!documentId) return; + const eventKind = String(payload.eventKind || ''); + const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); + if (documentId && !targetsCurrentDocument) return; + if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + if (targetsCurrentDocument && targetSession.saving) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) { + markSessionExternalConflict(targetSession, externalConflictMessage); + return; + } + scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); + }); + }); + eventSource.onerror = () => { + console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); + }; + localFolderEventRegistry.set(channelKey, channel); + } + channel.sessions.set(session.key, session); + session.localFolderChannel = channel; + }; + + const readTreePayloadData = (payload) => ( + payload && typeof payload === 'object' + ? (payload.data || payload.delta || payload) + : null + ); + + const readTreePayloadOverview = (payload) => ( + payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object' + ? payload.overview + : null + ); + + const readTreePayloadCursor = (payload) => { + const raw = String(payload?.cursor || payload?.revision || '').trim(); + if (!raw) return { id: '', createdAt: '', raw: '' }; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return { + id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(), + createdAt: String(parsed.createdAt || parsed.created_at || '').trim(), + raw, + }; + } + } catch (_) {} + return { id: raw, createdAt: '', raw }; + }; + + const treeRecordMatchesPayloadCursor = (record, payload) => { + if (!record || typeof record !== 'object') return false; + const cursor = readTreePayloadCursor(payload); + if (!cursor.id && !cursor.createdAt && !cursor.raw) return false; + const ids = [ + record.id, + record._id, + record.command_log_id, + record.commandLogId, + record.domain_event_id, + record.domainEventId, + record.command_id, + record.commandId, + ].map((value) => String(value || '').trim()).filter(Boolean); + if (cursor.id && ids.includes(cursor.id)) return true; + const createdAt = String(record.created_at || record.createdAt || '').trim(); + return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt); + }; + + const treeRecordTargetsDocument = (record, documentId) => { + if (!record || typeof record !== 'object' || !documentId) return false; + const targetPageId = String(record.target_page_id || record.targetPageId || '').trim(); + const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); + if (targetPageId === documentId || aggregateId === documentId) return true; + const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; + if (!payload) return false; + const streamDelta = payload.streamDelta || payload.stream_delta || null; + const deltaDocumentId = streamDelta && typeof streamDelta === 'object' + ? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim() + : ''; + return deltaDocumentId === documentId; + }; + + const collectMindmapIdsFromTreeRecord = (record, documentId, out) => { + if (!record || typeof record !== 'object' || !documentId) return; + if (!treeRecordTargetsDocument(record, documentId)) return; + const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim(); + if (targetBlockId) out.add(targetBlockId); + const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim(); + const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); + if (aggregateType === 'block' && aggregateId) out.add(aggregateId); + const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; + const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null; + const blockId = streamDelta && typeof streamDelta === 'object' + ? String(streamDelta.blockId || streamDelta.block_id || '').trim() + : ''; + if (blockId) out.add(blockId); + }; + + const collectMindmapIdsFromTreePayload = (payload, session) => { + const ids = new Set(); + if (!payload || typeof payload !== 'object' || !session?.documentId) return []; + const kind = String(payload.kind || '').trim(); + const data = readTreePayloadData(payload); + if (kind === 'delta' && data && typeof data === 'object') { + const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); + if (!documentId || documentId === session.documentId) { + const blockId = String(data.blockId || data.block_id || '').trim(); + if (blockId) ids.add(blockId); + const streamDelta = data.streamDelta || data.stream_delta || null; + const streamBlockId = streamDelta && typeof streamDelta === 'object' + ? String(streamDelta.blockId || streamDelta.block_id || '').trim() + : ''; + if (streamBlockId) ids.add(streamBlockId); + } + } + if (kind === 'resync') { + const overview = readTreePayloadOverview(payload); + if (overview) { + const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; + const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; + commandLogs + .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) + .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); + domainEvents + .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) + .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); + } + } + return Array.from(ids); + }; + + const refreshMindmapRuntimesFromTreePayload = (payload, session) => { + const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {}; + collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => { + const bridge = registry[mindmapId]; + if (bridge && typeof bridge.refreshProjection === 'function') { + void bridge.refreshProjection('mnote-web-tree-live'); + } + }); + }; + + const treePayloadTargetsDocument = (payload, session) => { + if (!payload || typeof payload !== 'object' || !session?.documentId) return false; + if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false; + const data = readTreePayloadData(payload); + if (data && typeof data === 'object') { + const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); + if (documentId === session.documentId) return true; + const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : []; + if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true; + } + const overview = readTreePayloadOverview(payload); + if (!overview) return false; + const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; + const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; + return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId)) + || domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId)); + }; + + const handleTreeExternalChange = (event) => { + const payload = event?.detail?.payload || event?.detail || null; + if (!payload) return; + Array.from(documentSessionRegistry.values()).forEach((session) => { + if (session.sourceKind === 'local_folder') return; + if (!treePayloadTargetsDocument(payload, session)) return; + refreshMindmapRuntimesFromTreePayload(payload, session); + session.lastExternalChangeSignalAt = Date.now(); + session.externalChangePending = true; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, treeExternalConflictMessage); + return; + } + scheduleSessionExternalRefresh(session, 'mnote-web-tree-live'); + }); + }; + + const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => { + if (!session) return false; + const doc = String(documentId || '').trim(); + const workspace = String(workspaceId || '').trim(); + if (doc && session.documentId !== doc) return false; + if (workspace && session.workspaceId && session.workspaceId !== workspace) return false; + return true; + }; + + const refreshDocumentSessionsFromExternalWrite = (detail, source) => { + const documentId = String(detail?.documentId || '').trim(); + const workspaceId = String(detail?.workspaceId || '').trim(); + let scheduled = 0; + Array.from(documentSessionRegistry.values()).forEach((session) => { + if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return; + session.lastExternalChangeSignalAt = Date.now(); + session.externalChangePending = true; + session.lastExternalWriteSource = source || session.lastExternalWriteSource || ''; + session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim(); + session.lastExternalWriteDocumentId = documentId; + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage); + return; + } + scheduled += 1; + scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write'); + }); + return scheduled; + }; + + window.addEventListener('tree:delta', handleTreeExternalChange); + window.addEventListener('tree:resync', handleTreeExternalChange); + window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { + refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); + }); + + const createDocumentSession = (runtimeDescriptor) => { + const pageBody = runtimeDescriptor.aggregate.body || {}; + const permissions = runtimeDescriptor.aggregate.head?.permissions || {}; + const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody); + const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null; + const keyRevision = revisionFromConflictKey(conflictDetectionKey); + const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); + const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); + const session = { + key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), + documentId: runtimeDescriptor.bootstrap.documentId, + workspaceId: runtimeDescriptor.bootstrap.workspaceId, + sourceKind, + rootUri: runtimeDescriptor.bootstrap.rootUri, + saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save', + pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', + latestAggregate: runtimeDescriptor.aggregate, + title: runtimeDescriptor.aggregate.head?.title || '无标题', + currentTiptapDocument: tiptapDocument, + currentSerialized: JSON.stringify(tiptapDocument), + lastPersistedSerialized: JSON.stringify(tiptapDocument), + revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision, + conflictDetectionKey, + fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, + lastExternalConflictDetectionKey: conflictDetectionKey || '', + readOnly: Boolean(permissions.readOnly), + dirty: false, + saving: false, + hasExternalConflict: false, + externalChangePending: false, + externalRefreshSource: '', + lastExternalChangeSignalAt: 0, + lastSelfSaveSignalAt: 0, + lastUserInputAt: 0, + status: 'booting', + error: null, + saveTimer: 0, + externalRefreshTimer: 0, + releaseTimer: 0, + views: new Map(), + localFolderChannel: null, + }; + ensureLocalFolderEventChannel(session); + return session; + }; + + const getOrCreateDocumentSession = (runtimeDescriptor) => { + const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap); + const existing = documentSessionRegistry.get(key); + if (existing) { + cancelDocumentSessionRelease(existing); + return existing; + } + const session = createDocumentSession(runtimeDescriptor); + documentSessionRegistry.set(key, session); + return session; + }; + + const updatePaneChrome = (runtimeDescriptor) => { + const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); + if (!(pane instanceof HTMLElement)) return; + const aggregate = runtimeDescriptor.aggregate || {}; + const bootstrap = runtimeDescriptor.bootstrap || {}; + const title = aggregate?.head?.title || '无标题'; + const documentId = bootstrap.documentId || aggregate?.identity?.documentId || ''; + const workspaceId = bootstrap.workspaceId || aggregate?.identity?.workspaceId || ''; + pane.setAttribute('data-pane-document-id', documentId); + pane.setAttribute('data-pane-workspace-id', workspaceId); + pane.setAttribute('data-pane-visible', 'true'); + pane.removeAttribute('data-mnote-side-target'); + pane.hidden = false; + const shell = pane.querySelector('.document-shell'); + if (shell instanceof HTMLElement) { + shell.setAttribute('data-document-id', documentId); + shell.setAttribute('data-workspace-id', workspaceId); + const options = aggregate?.layout?.pageOptions || {}; + shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout))); + shell.setAttribute('data-page-small-text', String(Boolean(options.smallText))); + shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal')); + shell.setAttribute('data-page-font', String(options.pageFont || 'default')); + shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers))); + } + pane.querySelectorAll('[data-page-title-input="true"]').forEach((node) => { + if (!(node instanceof HTMLTextAreaElement)) return; + node.value = title; + node.setAttribute('data-document-id', documentId); + node.setAttribute('data-workspace-id', workspaceId); + node.setAttribute('data-title-last-saved', title); + node.setAttribute('data-title-save-status', 'saved'); + node.style.height = 'auto'; + node.style.height = `${Math.max(48, node.scrollHeight)}px`; + }); + pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => { + if (node instanceof HTMLElement) node.textContent = title; + }); + const pageTab = document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${runtimeDescriptor.paneRole}"]`); + if (pageTab instanceof HTMLElement) { + pageTab.setAttribute('data-document-id', documentId); + pageTab.setAttribute('data-workspace-id', workspaceId); + const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title'); + if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title; + } + if (runtimeDescriptor.paneRole === 'primary') { + document.body.dataset.documentId = documentId; + document.body.dataset.mnoteShell = 'document'; + delete document.body.dataset.mindmapId; + document.title = title; + const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); + if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', { + detail: { documentId, workspaceId, title } + })); + document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); + document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); + document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { + if (row instanceof HTMLElement) { + row.setAttribute('data-active', 'false'); + row.setAttribute('data-selected', 'false'); + } + }); + const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&'); + document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => { + if (!(row instanceof HTMLElement)) return; + if (row.getAttribute('data-shell-mode') === 'filetree') { + row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`)); + } else { + row.setAttribute('data-active', 'true'); + } + }); + } + runtimeDescriptor.root.removeAttribute('data-mnote-object-editor'); + runtimeDescriptor.root.removeAttribute('data-mnote-object-identity'); + runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id'); + runtimeDescriptor.root.removeAttribute('data-mnote-side-target-unsupported'); + runtimeDescriptor.root.removeAttribute('data-mnote-side-target-asset-id'); + }; + + const fetchPageAggregateForPane = async (descriptor) => { + const response = await fetch(pageAggregateUrlFromDescriptor(descriptor).toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`page_aggregate_failed_${response.status}`); + const payload = await response.json(); + if (!payload?.result) throw new Error('page_aggregate_missing_result'); + return payload.result; + }; + + const replacePaneDocument = async (paneRole, descriptor, options = {}) => { + unmountMindmapPane(paneRole); + const runtime = await loadRuntime(); + const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); + const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`); + if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`); + const previousView = paneViewRegistry.get(paneRole); + if (previousView) { + unmountEditorViewBinding(previousView); + paneViewRegistry.delete(paneRole); + } + const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor); + const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole); + const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap }; + updatePaneChrome(runtimeDescriptor); + if (paneRole === 'secondary' && workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'true'); + setSecondaryEditorHostVisible(true); + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; + applyStoredSecondaryWidth(); + } + const session = getOrCreateDocumentSession(runtimeDescriptor); + const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + const mountOptions = { + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {}, + }; + setStatus(runtimeDescriptor, 'loading-assets'); + clearEmbeddedLocalDraft(runtimeDescriptor); + try { + const mountId = runtime.mount(runtimeDescriptor.root, mountOptions); + view.mountId = mountId; + runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); + runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); + setStatus(runtimeDescriptor, 'mounting-editor'); + paneViewRegistry.set(paneRole, view); + return view; + } catch (error) { + unmountEditorViewBinding(view); + throw error; + } + }; + + const descriptorFromCurrentUrl = (paneRole, documentId, explicit = {}) => { + const url = currentUrl(); + if (paneRole === 'secondary') { + return { + documentId, + workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', + sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace', + rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '', + }; + } + return { + documentId, + workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', + sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace', + rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '', + }; + }; + + const updatePrimaryUrl = (descriptor, urlFromCaller) => { + const url = urlFromCaller instanceof URL + ? urlFromCaller + : new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); + if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); + if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind); + if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); + pushUrlState(url); + }; + + const setSecondaryEditorHostVisible = (visible) => { + const host = document.querySelector('[data-testid="mnote-secondary-editor-tab-host"]'); + if (host instanceof HTMLElement) host.hidden = !visible; + }; + + const closeSecondaryPane = (url) => { + const view = paneViewRegistry.get('secondary'); + if (view) { + unmountEditorViewBinding(view); + paneViewRegistry.delete('secondary'); + } + const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); + if (pane instanceof HTMLElement) { + pane.hidden = true; + pane.setAttribute('data-pane-visible', 'false'); + pane.removeAttribute('data-mnote-side-target'); + pane.removeAttribute('data-pane-document-id'); + pane.removeAttribute('data-pane-workspace-id'); + } + const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`); + if (root instanceof HTMLElement) { + root.replaceChildren(); + root.removeAttribute('data-mnote-side-target-unsupported'); + root.removeAttribute('data-mnote-side-target-asset-id'); + } + Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => { + if (normalizePaneRole(entry?.paneRole) !== 'secondary') return; + if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); + if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { + try { + entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); + } catch (error) { + console.warn('mnote secondary mindmap resource tab unmount failed', error); + } + } + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + removeFromResourceTabMru('secondary', key); + }); + if (workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'false'); + workspace.style.removeProperty('grid-template-columns'); + } + setSecondaryEditorHostVisible(false); + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = true; + document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); + document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); + replaceUrlState(url); + }; + + const parseJsonScriptFromDocument = (doc, id) => { + const node = doc?.getElementById?.(id); + if (!node) return null; + try { + return JSON.parse(node.textContent || 'null'); + } catch (error) { + console.warn(`mnote mindmap shell JSON 解析失败: ${id}`, error); + return null; + } + }; + + const fetchMindmapShellBootstrap = async (url) => { + const response = await fetch(url.toString(), { + cache: 'no-store', + credentials: 'include', + headers: { accept: 'text/html' }, + }); + if (!response.ok) throw new Error(`mindmap_shell_failed_${response.status}`); + const html = await response.text(); + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const bootstrap = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__'); + if (!bootstrap || typeof bootstrap !== 'object') throw new Error('mindmap_shell_missing_bootstrap'); + const contract = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_SHELL__') || {}; + const title = String(bootstrap.title || parsed.querySelector('title')?.textContent || '思维导图').trim() || '思维导图'; + return { bootstrap, contract, title }; + }; + + const setPrimaryMindmapSelection = (documentId, mindmapId) => { + const cssEscape = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape : (value) => String(value).replace(/["\\]/g, '\\$&'); + const escapedDocId = cssEscape(documentId); + const escapedMindmapId = cssEscape(mindmapId); + document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + row.setAttribute('data-active', 'false'); + row.setAttribute('data-selected', 'false'); + }); + document.querySelectorAll(`.tree-row[data-shell-mode="page"][data-node-id="${escapedDocId}"]`).forEach((row) => { + if (row instanceof HTMLElement) row.setAttribute('data-active', 'true'); + }); + document.querySelectorAll(`.tree-row[data-shell-mode="filetree"][data-asset-id="${escapedMindmapId}"]`).forEach((row) => { + if (row instanceof HTMLElement) row.setAttribute('data-selected', 'true'); + }); + }; + + const updatePrimaryMindmapChrome = ({ documentId, mindmapId, workspaceId, title, root }) => { + const pane = root.closest('[data-document-pane="true"]'); + if (pane instanceof HTMLElement) { + pane.setAttribute('data-pane-document-id', `__mindmap_object__:${documentId}:${mindmapId}`); + pane.setAttribute('data-pane-workspace-id', workspaceId || ''); + pane.setAttribute('data-pane-visible', 'true'); + pane.hidden = false; + } + const shell = root.closest('.document-shell'); + if (shell instanceof HTMLElement) { + shell.setAttribute('data-editor-host', 'mindmap_object'); + shell.setAttribute('data-document-id', documentId); + shell.setAttribute('data-workspace-id', workspaceId || ''); + shell.setAttribute('data-mindmap-id', mindmapId); + } + document.body.dataset.documentId = documentId; + document.body.dataset.mindmapId = mindmapId; + document.body.dataset.mnoteShell = 'mindmap'; + document.title = title; + document.querySelectorAll('[data-page-title-input="true"][data-pane-role="primary"]').forEach((node) => { + if (!(node instanceof HTMLTextAreaElement)) return; + node.value = title; + node.setAttribute('data-document-id', documentId); + node.setAttribute('data-workspace-id', workspaceId || ''); + node.setAttribute('data-title-last-saved', title); + node.setAttribute('data-title-save-status', 'saved'); + node.style.height = 'auto'; + node.style.height = `${Math.max(48, node.scrollHeight)}px`; + }); + document.querySelectorAll('[data-page-title-current="true"]').forEach((node) => { + if (node instanceof HTMLElement) node.textContent = title; + }); + const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); + if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + root.setAttribute('data-mnote-object-editor', 'mindmap'); + root.setAttribute('data-mnote-object-identity', `resource:mindmap:${documentId}:${mindmapId}`); + root.setAttribute('data-mnote-mindmap-id', mindmapId); + setPrimaryMindmapSelection(documentId, mindmapId); + }; + + const replacePrimaryPaneMindmap = async ({ documentId, mindmapId, workspaceId, url }) => { + const targetUrl = url instanceof URL + ? url + : new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin); + const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`); + const observability = document.querySelector('[data-editor-host-observability][data-pane-role="primary"]'); + if (!(root instanceof HTMLElement)) throw new Error('primary_pane_root_missing'); + const runtime = await loadRuntime(); + const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl); + const previousView = paneViewRegistry.get('primary'); + if (previousView) { + unmountEditorViewBinding(previousView); + paneViewRegistry.delete('primary'); + } + unmountMindmapPane('primary'); + if (observability instanceof HTMLElement) { + observability.setAttribute('data-editor-host-active', 'mindmap_object'); + observability.setAttribute('data-editor-host-status', 'mounting'); + } + root.replaceChildren(); + root.setAttribute('data-runtime-editor-status', 'booting'); + updatePrimaryMindmapChrome({ documentId, mindmapId, workspaceId, title, root }); + const mountId = runtime.mount(root, bootstrap); + root.setAttribute('data-runtime-mount-id', String(mountId)); + root.setAttribute('data-editor-host-kind', 'mindmap_object'); + if (observability instanceof HTMLElement) { + observability.setAttribute('data-editor-host-status', 'mounted'); + } + mindmapPaneViewRegistry.set('primary', { paneRole: 'primary', root, runtime, mountId }); + pushUrlState(targetUrl); + return true; + }; + + const handleSessionChange = (session, view, event) => { + const payload = normalizeEnvelopePayload(event); + if (!payload) return; + const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending; + const recentExternalChange = sessionHasRecentExternalSignal(session); + const recentLocalInput = sessionHasRecentLocalInput(session); + const tiptapDocument = hydrateMindmapAttrsFromDom(toTiptapDocument( + payload?.tiptapDocument || payload?.editorDocument || payload?.content, + currentEditorText(view), + ), view.runtimeDescriptor.root); + const serialized = JSON.stringify(tiptapDocument); + if (view.suppressedSerialized && view.suppressedSerialized === serialized) { + view.suppressedSerialized = null; + view.lastKnownSerialized = serialized; + return; + } + const previousSerialized = session.currentSerialized; + session.currentTiptapDocument = tiptapDocument; + session.currentSerialized = serialized; + view.lastKnownSerialized = serialized; + if (typeof payload?.title === 'string' && payload.title.trim()) { + session.title = payload.title.trim(); + } + if (payload?.meta && typeof payload.meta === 'object') { + if (Number.isInteger(payload.meta.revision)) session.revision = payload.meta.revision; + if (typeof payload.meta.conflictDetectionKey === 'string' && payload.meta.conflictDetectionKey.trim()) { + const nextMetaConflictKey = payload.meta.conflictDetectionKey.trim(); + if (conflictDetectionKeyBelongsToSession(session, nextMetaConflictKey)) { + session.conflictDetectionKey = nextMetaConflictKey; + } + } + if (typeof payload.meta.readOnly === 'boolean') { + session.readOnly = payload.meta.readOnly; + } + } + session.dirty = session.currentSerialized !== session.lastPersistedSerialized; + syncResourceSessionTabGuards(session); + if (serialized !== previousSerialized) { + broadcastSessionContent(session, view); + } + if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) { + markSessionExternalConflict(session, externalConflictMessage); + return; + } + if (session.hasExternalConflict) { + setSessionStatus(session, 'external-change-conflict', externalConflictMessage); + return; + } + if (session.dirty) { + queueSessionSave(session); + } else { + setSessionStatus(session, 'saved'); + } + }; + + const unmountEditorViewBinding = (view, options = {}) => { + if (!view || view.disposed) return; + view.disposed = true; + const scheduleRelease = options.scheduleRelease !== false; + const root = view.runtimeDescriptor.root; + if (typeof view.disconnectObserver === 'function') { + view.disconnectObserver(); + view.disconnectObserver = null; + } + if (view.onReady) root.removeEventListener(READY_EVENT, view.onReady); + if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError); + if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange); + if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave); + if (view.onState) root.removeEventListener(STATE_EVENT, view.onState); + if (view.onKeydown) root.removeEventListener('keydown', view.onKeydown, true); + if (view.onInput) root.removeEventListener('input', view.onInput); + if (view.disconnectSlashObserver) { + view.disconnectSlashObserver(); + view.disconnectSlashObserver = null; + } + clearSessionConflictSurface(view.session); + if (view.mountId != null && typeof view.runtime?.unmount === 'function') { + try { + view.runtime.unmount(view.mountId); + } catch (error) { + console.warn('mnote editor view unmount failed', error); + } + } + view.mountId = null; + root.removeAttribute('data-runtime-mount-id'); + if (view.session.views.get(view.id) === view) { + view.session.views.delete(view.id); + } + if (scheduleRelease) { + scheduleDocumentSessionRelease(view.session); + } + }; + + const observeEditorViewBinding = (view) => { + if (typeof MutationObserver !== 'function' || !(document.body instanceof HTMLElement)) { + return; + } + const observer = new MutationObserver(() => { + if (!view.disposed && !view.runtimeDescriptor.root.isConnected) { + unmountEditorViewBinding(view); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + view.disconnectObserver = () => observer.disconnect(); + }; + + const activeEditorRootForSlashMenu = () => { + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode || null; + const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode + : anchorNode?.parentElement || null; + const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => ( + node instanceof HTMLElement + && node.offsetParent !== null + && node.getAttribute('data-mnote-side-target-unsupported') !== 'true' + )); + const intendedRoot = window.__mnoteIntendedSlashRoot instanceof HTMLElement + && roots.includes(window.__mnoteIntendedSlashRoot) + ? window.__mnoteIntendedSlashRoot + : null; + if (intendedRoot) return intendedRoot; + const focused = document.activeElement instanceof Element + ? roots.find((root) => root.contains(document.activeElement)) + : null; + if (focused) return focused; + if (anchorElement instanceof Element) { + const activeRoot = roots.find((root) => root.contains(anchorElement)); + if (activeRoot) return activeRoot; + } + return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null; + }; + + const markIntendedSlashRoot = (entry) => { + const root = entry?.view?.runtimeDescriptor?.root; + if (!(root instanceof HTMLElement) || !root.isConnected) return; + window.__mnoteIntendedSlashRoot = root; + hideSlashMenusOutsideRoot(root); + scheduleSlashMenuPosition(root); + }; + + const slashMenuAnchorFromSelection = (root) => { + const currentBlockFromSelection = () => { + try { + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode || null; + const editor = root.querySelector('.ProseMirror'); + const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode + : anchorNode?.parentElement || null; + if (!(editor instanceof HTMLElement) || !(anchorElement instanceof Element) || !editor.contains(anchorElement)) return null; + let current = anchorElement; + while (current && current.parentElement && current.parentElement !== editor) { + current = current.parentElement; + } + return current instanceof HTMLElement && current.parentElement === editor ? current : null; + } catch (_) { + return null; + } + }; + const fallback = () => { + const editor = root.querySelector('.ProseMirror'); + if (editor instanceof HTMLElement) { + const block = currentBlockFromSelection() + || Array.from(editor.children).find((node) => node instanceof HTMLElement && node.matches(':focus-within, .ProseMirror-selectednode')) + || Array.from(editor.children).find((node) => node instanceof HTMLElement && node.getBoundingClientRect().height > 0); + if (block instanceof HTMLElement) { + const rect = block.getBoundingClientRect(); + return { top: rect.bottom + 8, left: rect.left, anchorTop: rect.top }; + } + } + const rect = root.getBoundingClientRect(); + return { top: rect.top + 24, left: rect.left + 24, anchorTop: rect.top + 24 }; + }; + try { + const selection = window.getSelection(); + if (!selection || selection.rangeCount <= 0) return fallback(); + const anchorNode = selection.anchorNode; + if (anchorNode && !root.contains(anchorNode.nodeType === Node.ELEMENT_NODE ? anchorNode : anchorNode.parentElement)) { + return fallback(); + } + const rect = selection.getRangeAt(0).getBoundingClientRect(); + if (rect && (rect.top > 0 || rect.left > 0 || rect.height > 0)) { + return { top: rect.bottom + 8, left: rect.left, anchorTop: rect.top }; + } + } catch (_) {} + return fallback(); + }; + + const setSlashMenuInactive = (menu, inactive) => { + if (!(menu instanceof HTMLElement)) return; + if (inactive) { + if (menu.getAttribute('data-mnote-slash-inactive') !== 'true') { + menu.setAttribute('data-mnote-slash-inactive', 'true'); + } + if (menu.style.display !== 'none') menu.style.display = 'none'; + return; + } + if (menu.getAttribute('data-mnote-slash-inactive') === 'true') { + menu.removeAttribute('data-mnote-slash-inactive'); + } + if (menu.style.display === 'none') menu.style.display = ''; + }; + + const setSlashMenuStyle = (menu, property, value) => { + if (!(menu instanceof HTMLElement)) return; + if (menu.style[property] !== value) menu.style[property] = value; + }; + + const setSlashMenuAttribute = (menu, name, value) => { + if (!(menu instanceof HTMLElement)) return; + if (menu.getAttribute(name) !== value) menu.setAttribute(name, value); + }; + + const hideSlashMenusOutsideRoot = (activeRoot) => { + document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => { + const root = menu.closest(ROOT_SELECTOR); + if (root !== activeRoot) setSlashMenuInactive(menu, true); + }); + }; + + const positionSlashMenuForRoot = (root) => { + const activeRoot = activeEditorRootForSlashMenu(); + if (!(root instanceof HTMLElement)) root = activeRoot; + if (!(root instanceof HTMLElement)) return; + if (activeRoot instanceof HTMLElement && root !== activeRoot) { + const inactiveMenu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + setSlashMenuInactive(inactiveMenu, true); + hideSlashMenusOutsideRoot(activeRoot); + return; + } + hideSlashMenusOutsideRoot(root); + const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + if (!(menu instanceof HTMLElement)) return; + setSlashMenuInactive(menu, false); + const anchor = slashMenuAnchorFromSelection(root); + const gap = 8; + const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16)); + const menuHeight = Math.min(430, Math.max(120, window.innerHeight - 16)); + const left = Math.min(Math.max(gap, anchor.left), Math.max(gap, window.innerWidth - menuWidth - gap)); + const belowTop = Math.min(Math.max(gap, anchor.top), Math.max(gap, window.innerHeight - 120)); + let top = belowTop + menuHeight > window.innerHeight - gap + ? Math.min(Math.max(gap, anchor.anchorTop - menuHeight - gap), Math.max(gap, window.innerHeight - 120)) + : belowTop; + const mindmap = document.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + if (mindmap instanceof HTMLElement) { + const mindmapRect = mindmap.getBoundingClientRect(); + const overlapsMindmap = top < mindmapRect.bottom && top + menuHeight > mindmapRect.top; + if (overlapsMindmap && mindmapRect.bottom > window.innerHeight - 48) { + top = Math.max(top, Math.max(gap, window.innerHeight - menuHeight - gap)); + } else if (overlapsMindmap && mindmapRect.bottom + menuHeight + gap <= window.innerHeight) { + top = mindmapRect.bottom + gap; + } + } + setSlashMenuStyle(menu, 'position', 'fixed'); + setSlashMenuStyle(menu, 'zIndex', '130'); + setSlashMenuStyle(menu, 'left', `${Math.round(left)}px`); + setSlashMenuStyle(menu, 'top', `${Math.round(top)}px`); + setSlashMenuStyle(menu, 'width', `min(316px, calc(100vw - 16px))`); + setSlashMenuStyle(menu, 'maxHeight', `min(430px, calc(100vh - 16px))`); + setSlashMenuAttribute(menu, 'data-mnote-slash-positioned', 'host'); + }; + + let pendingSlashMenuPositionFrame = 0; + let pendingSlashMenuPositionRoot = null; + const scheduleSlashMenuPosition = (root) => { + if (root instanceof HTMLElement) pendingSlashMenuPositionRoot = root; + if (pendingSlashMenuPositionFrame) return; + pendingSlashMenuPositionFrame = window.requestAnimationFrame(() => { + const targetRoot = pendingSlashMenuPositionRoot; + pendingSlashMenuPositionFrame = 0; + pendingSlashMenuPositionRoot = null; + positionSlashMenuForRoot(targetRoot); + }); + }; + + const shouldReactToSlashMenuMutation = (records, root) => { + if (!Array.isArray(records) || !(root instanceof HTMLElement)) return false; + return records.some((record) => { + const target = record && record.target instanceof HTMLElement ? record.target : null; + if (!target || !root.contains(target) && target !== root) return false; + if (record.type === 'attributes') { + return record.attributeName !== 'style' && record.attributeName !== 'data-mnote-slash-positioned' && record.attributeName !== 'data-mnote-slash-inactive'; + } + if (record.type === 'childList') { + return Array.from(record.addedNodes || []).some((node) => node instanceof HTMLElement && node.closest('[data-testid="mnote-leptos-tiptap-slash-menu"]')) + || Array.from(record.removedNodes || []).some((node) => node instanceof HTMLElement && node.closest('[data-testid="mnote-leptos-tiptap-slash-menu"]')); + } + return true; + }); + }; + + const scheduleGlobalSlashMenuPosition = () => { + scheduleSlashMenuPosition(activeEditorRootForSlashMenu()); + }; + + const installGlobalSlashMenuPositioning = () => { + if (window.__MNOTE_SLASH_MENU_POSITIONING_INSTALLED__) return; + window.__MNOTE_SLASH_MENU_POSITIONING_INSTALLED__ = true; + if (typeof MutationObserver === 'function' && document.body instanceof HTMLElement) { + const observer = new MutationObserver(() => scheduleGlobalSlashMenuPosition()); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden'] }); + } + document.addEventListener('keydown', (event) => { + if (event.key === '/') scheduleGlobalSlashMenuPosition(); + }, true); + document.addEventListener('selectionchange', () => scheduleGlobalSlashMenuPosition(), true); + window.addEventListener('resize', scheduleGlobalSlashMenuPosition); + window.addEventListener('scroll', scheduleGlobalSlashMenuPosition, true); + }; + + installGlobalSlashMenuPositioning(); + + const observeSlashMenuPosition = (view) => { + const root = view.runtimeDescriptor.root; + if (!(root instanceof HTMLElement) || typeof MutationObserver !== 'function') return; + const observer = new MutationObserver((records) => { + if (!view.disposed && shouldReactToSlashMenuMutation(records, root)) { + scheduleSlashMenuPosition(root); + } + }); + observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'hidden', 'class', 'data-mnote-slash-positioned', 'data-mnote-slash-inactive'] }); + view.disconnectSlashObserver = () => observer.disconnect(); + }; + + const createEditorViewBinding = (session, runtime, runtimeDescriptor) => { + cancelDocumentSessionRelease(session); + const view = { + id: nextViewId++, + mountId: null, + ready: false, + disposed: false, + suppressedSerialized: null, + lastKnownSerialized: session.currentSerialized, + runtime, + runtimeDescriptor, + session, + disconnectObserver: null, + onReady: null, + onError: null, + onChange: null, + onSave: null, + onState: null, + onKeydown: null, + onInput: null, + disconnectSlashObserver: null, + }; + + view.onReady = () => { + view.ready = true; + if (view.lastKnownSerialized !== session.currentSerialized) { + dispatchSessionContentToView(session, view, 'mnote-web-document-session-ready-sync'); + } + if (session.status === 'dirty' || session.status === 'saving' || session.status === 'saved' || session.status === 'error' || session.status === 'external-change-conflict' || session.status === 'synced-external-change') { + setStatus(runtimeDescriptor, session.status, session.error); + } else { + setStatus(runtimeDescriptor, 'ready'); + } + }; + view.onError = (event) => { + const payload = normalizeEnvelopePayload(event); + setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error'); + }; + view.onChange = (event) => { + scheduleSlashMenuPosition(runtimeDescriptor.root); + handleSessionChange(session, view, event); + }; + view.onSave = (event) => { + handleSessionChange(session, view, event); + }; + view.onState = () => { + scheduleSlashMenuPosition(runtimeDescriptor.root); + }; + view.onKeydown = (event) => { + if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root); + }; + view.onInput = (event) => { + const target = event.target; + if (!(target instanceof Element) || !target.closest('.ProseMirror')) return; + session.lastUserInputAt = Date.now(); + scheduleSlashMenuPosition(runtimeDescriptor.root); + }; + + runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady); + runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError); + runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange); + runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave); + runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState); + runtimeDescriptor.root.addEventListener('keydown', view.onKeydown, true); + runtimeDescriptor.root.addEventListener('input', view.onInput); + session.views.set(view.id, view); + observeSlashMenuPosition(view); + observeEditorViewBinding(view); + return view; + }; + + const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary'; + + const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`; + + const resourceTabHostNodes = (paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + return { + paneRole: role, + strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`), + pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`), + pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`), + host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`), + panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`), + }; + }; + + const resourceTabBadgeKind = (input, kind) => { + const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); + if (kind === 'mindmap') return 'mindmap'; + if (kind === 'office') { + if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt'; + if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet'; + return 'word'; + } + if (kind === 'pdf') return 'pdf'; + if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code'; + if (kind === 'image') return 'image'; + return 'file'; + }; + + const currentWebShellWorkspaceId = () => { + try { + return currentUrl().searchParams.get('workspaceId') || ''; + } catch (_) { + return ''; + } + }; + + const currentWebShellDocumentId = () => { + const fromBody = document.body?.dataset?.documentId || ''; + if (fromBody) return String(fromBody).trim(); + const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); + if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim(); + return ''; + }; + + const normalizeResourceTabKind = (input) => { + const kind = String(input?.kind || '').trim().toLowerCase(); + const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); + if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind; + if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office'; + if (/\.pdf$/i.test(title)) return 'pdf'; + if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image'; + if (/\.(md|markdown)$/i.test(title)) return 'markdown'; + if (/\.(txt|log)$/i.test(title)) return 'text'; + if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code'; + return 'file'; + }; + + const resolveResourceOpen = (input = {}) => { + const kind = normalizeResourceTabKind(input); + const badgeKind = resourceTabBadgeKind(input, kind); + const rawTarget = String(input?.openTarget || '').trim().toLowerCase(); + const openTarget = rawTarget === 'new-window' || rawTarget === 'side' ? rawTarget : 'active-tab'; + const editable = kind === 'markdown' || kind === 'text' || kind === 'code'; + const defaultOpenMode = kind === 'office' && openTarget === 'active-tab' ? 'active-tab-iframe' : openTarget; + const viewerUrl = kind === 'office' + ? String(input?.officeUrl || input?.href || '').trim() + : String(input?.href || '').trim(); + return { editorKind: kind, badgeKind, defaultOpenMode, editable, viewerUrl, openTarget }; + }; + + const touchResourceTabMru = (paneRole, key) => { + const role = normalizePaneRole(paneRole); + const id = String(key || '').trim(); + if (!id) return; + const list = resourceTabMru[role] || (resourceTabMru[role] = []); + const index = list.indexOf(id); + if (index >= 0) list.splice(index, 1); + list.unshift(id); + if (list.length > resourceTabMruMax) list.length = resourceTabMruMax; + }; + + const setResourceTabLastActive = (key) => { + const entry = resourceTabRegistry.get(String(key || '').trim()); + if (entry?.session) entry.session.lastActiveAt = Date.now(); + }; + + const removeFromResourceTabMru = (paneRole, key) => { + const list = resourceTabMru[normalizePaneRole(paneRole)] || []; + const index = list.indexOf(key); + if (index >= 0) list.splice(index, 1); + }; + + const lastActiveResourceTabKey = (paneRole = 'primary') => { + const list = resourceTabMru[normalizePaneRole(paneRole)] || []; + for (const key of list) { + if (resourceTabRegistry.has(key)) return key; + } + return ''; + }; + + const resourceTabCloseGuardReason = (session) => { + if (!session) return ''; + const hasUnsavedChanges = session.dirty + || Boolean(session.saveTimer) + || sessionHasRecentLocalInput(session) + || (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized); + if (hasUnsavedChanges) return 'dirty'; + if (session.saving) return 'saving'; + if (session.hasExternalConflict) return 'hasExternalConflict'; + return ''; + }; + + const syncResourceTabCloseGuard = (entry) => { + if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return; + const reason = resourceTabCloseGuardReason(entry.session); + if (reason) { + entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason); + entry.tab.classList.add('is-close-guarded'); + } else { + entry.tab.removeAttribute(resourceTabCloseGuardAttribute); + entry.tab.classList.remove('is-close-guarded'); + } + }; + + const syncResourceSessionTabGuards = (session) => { + if (!session || session.sessionKind !== 'resource') return; + resourceTabRegistry.forEach((entry) => { + if (entry.session === session) syncResourceTabCloseGuard(entry); + }); + }; + + const openEditorsSnapshotEntry = (entry, key) => ({ + objectIdentity: String(entry?.objectIdentity || key || '').trim(), + title: String(entry?.title || '资源').trim() || '资源', + kind: normalizeResourceTabKind(entry), + badgeKind: entry?.tab instanceof HTMLElement + ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim() + : resourceTabBadgeKind(entry, entry?.kind), + active: entry?.tab instanceof HTMLElement + ? entry.tab.getAttribute('aria-selected') === 'true' + : false, + dirtyGuard: resourceTabCloseGuardReason(entry?.session), + assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(), + path: String(entry?.path || entry?.session?.resourcePath || '').trim(), + }); + + const buildOpenEditorsSnapshot = () => { + const pageEntries = ['primary', 'secondary'].map((paneRole) => { + const nodes = resourceTabHostNodes(paneRole); + const pageTitle = nodes.pageTab instanceof HTMLElement + ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() + : '页面'; + return { + objectIdentity: `page:${paneRole}`, + paneRole, + documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(), + workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(), + title: pageTitle || '页面', + kind: 'page', + badgeKind: 'code', + active: nodes.pageTab instanceof HTMLElement + ? nodes.pageTab.getAttribute('aria-selected') === 'true' + : false, + dirtyGuard: '', + }; + }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]')); + const resources = []; + resourceTabRegistry.forEach((entry, key) => { + resources.push(openEditorsSnapshotEntry(entry, key)); + }); + const active = [...pageEntries, ...resources].find((entry) => entry.active); + return { + schema: 'mnote.open_editors_snapshot.v1', + generatedAt: Date.now(), + activeObjectIdentity: active?.objectIdentity || '', + editors: [...pageEntries, ...resources], + resourceEditors: resources, + }; + }; + + const syncOpenEditorsSnapshot = () => { + const snapshot = buildOpenEditorsSnapshot(); + window.__mnoteOpenEditorsSnapshot = snapshot; + document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length)); + document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || ''); + window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot })); + return snapshot; + }; + + const showResourceTabCloseGuardNotice = (entry, reason) => { + const nodes = resourceTabHostNodes(entry?.paneRole || 'primary'); + const messages = { + dirty: '当前资源有未保存的修改,保存完成后再关闭。', + saving: '当前资源正在保存中,请稍后再关闭。', + hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。', + }; + const message = messages[reason] || '当前资源暂时无法关闭。'; + let notice = document.getElementById('mnote-resource-close-guard-notice'); + if (!notice) { + notice = document.createElement('div'); + notice.id = 'mnote-resource-close-guard-notice'; + notice.className = 'mnote-close-guard-notice'; + notice.setAttribute('role', 'status'); + notice.setAttribute('aria-live', 'polite'); + notice.setAttribute('data-mnote-resource-close-guard', ''); + const parent = nodes.strip?.parentNode; + if (parent instanceof HTMLElement) { + const panels = parent.querySelector('.mnote-main-tab-panels'); + if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels); + else parent.append(notice); + } + } + notice.textContent = `${entry?.title || '资源'}:${message}`; + notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked'); + notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`; + if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer); + notice._mnoteHideTimer = window.setTimeout(() => { + notice.classList.add('is-hiding'); + window.setTimeout(() => { + if (notice.parentNode) notice.remove(); + }, 260); + }, 4000); + }; + + const cssSafe = (value) => { + const text = String(value || ''); + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text); + return text.replace(/["\\]/g, '\\$&'); + }; + + const syncActiveResourceFileTreeRow = (activeResource) => { + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => { + if (row instanceof HTMLElement) row.setAttribute('data-active', 'false'); + }); + const entry = activeResource ? resourceTabRegistry.get(activeResource) : null; + if (!entry) return; + const assetId = String(entry.assetId || entry.session?.assetId || '').trim(); + const path = String(entry.path || entry.session?.resourcePath || '').trim(); + const identity = String(entry.objectIdentity || activeResource || '').trim(); + if (assetId) { + const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`); + if (row instanceof HTMLElement) { + row.setAttribute('data-active', 'true'); + return; + } + } + const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]'); + for (const row of rows) { + if (!(row instanceof HTMLElement)) continue; + const objectIdentity = row.getAttribute('data-object-identity') || ''; + if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) { + row.setAttribute('data-active', 'true'); + return; + } + } + }; + + const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + if (role !== 'primary') return; + const url = currentUrl(); + if (activeResource) { + const entry = resourceTabRegistry.get(activeResource); + const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim(); + if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`; + url.searchParams.set('resourceTab', activeResource); + } else { + const documentId = currentWebShellDocumentId(); + if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`; + url.searchParams.delete('resourceTab'); + } + replaceUrlState(url); + }; + + const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => { + const activeResource = String(objectIdentity || '').trim(); + const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null; + const role = normalizePaneRole(activeEntry?.paneRole || paneRole); + const nodes = resourceTabHostNodes(role); + if (activeResource) { + touchResourceTabMru(role, activeResource); + setResourceTabLastActive(activeResource); + } + if (nodes.pageTab instanceof HTMLElement) { + const activePage = !activeResource; + nodes.pageTab.classList.toggle('is-active', activePage); + nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false'); + nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1'); + } + if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource); + if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource; + resourceTabRegistry.forEach((entry, key) => { + if (normalizePaneRole(entry.paneRole) !== role) return; + const active = key === activeResource; + if (entry.tab instanceof HTMLElement) { + entry.tab.classList.toggle('is-active', active); + entry.tab.setAttribute('aria-selected', active ? 'true' : 'false'); + entry.tab.setAttribute('tabindex', active ? '0' : '-1'); + } + if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active; + syncResourceTabCloseGuard(entry); + }); + if (activeEntry) markIntendedSlashRoot(activeEntry); + if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null; + if (role === 'primary') syncActiveResourceFileTreeRow(activeResource); + syncActiveResourceUrlState(activeResource, role); + syncOpenEditorsSnapshot(); + }; + + const bindMainEditorTabStrip = (paneRole = 'primary') => { + const nodes = resourceTabHostNodes(paneRole); + if (!(nodes.strip instanceof HTMLElement)) return; + if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return; + nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true'); + const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]')) + .filter((tab) => tab instanceof HTMLElement && tab.isConnected); + const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true'); + nodes.strip.addEventListener('keydown', (event) => { + const tabs = collectTabs(); + if (!tabs.length) return; + const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement); + const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs)); + let targetIndex = -1; + if (event.key === 'ArrowRight') { + targetIndex = (baseIndex + 1) % tabs.length; + } else if (event.key === 'ArrowLeft') { + targetIndex = (baseIndex - 1 + tabs.length) % tabs.length; + } else if (event.key === 'Home') { + targetIndex = 0; + } else if (event.key === 'End') { + targetIndex = tabs.length - 1; + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + const tab = tabs[baseIndex]; + if (tab instanceof HTMLElement) tab.click(); + return; + } else { + return; + } + event.preventDefault(); + const target = tabs[targetIndex]; + if (target instanceof HTMLElement) target.focus(); + }); + }; + + const bindMainEditorPageTab = (paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + const nodes = resourceTabHostNodes(role); + if (!(nodes.pageTab instanceof HTMLElement)) return; + bindMainEditorTabStrip(role); + if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return; + nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true'); + nodes.pageTab.addEventListener('click', (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return; + event.preventDefault(); + activateMainEditorTab('', role); + }); + syncOpenEditorsSnapshot(); + }; + + const closeResourceTab = (objectIdentity) => { + const key = String(objectIdentity || '').trim(); + const entry = resourceTabRegistry.get(key); + if (!entry) return; + const guardReason = resourceTabCloseGuardReason(entry.session); + if (guardReason) { + console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`); + syncResourceTabCloseGuard(entry); + showResourceTabCloseGuardNotice(entry, guardReason); + return; + } + removeFromResourceTabMru(entry.paneRole || 'primary', key); + if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); + if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { + try { + entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); + } catch (error) { + console.warn('mnote mindmap resource tab unmount failed', error); + } + } + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + const role = normalizePaneRole(entry.paneRole); + const nextKey = lastActiveResourceTabKey(role); + activateMainEditorTab(nextKey, role); + const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes(role).pageTab; + if (nextTab instanceof HTMLElement) nextTab.focus(); + }; + + const markResourceTabError = (entry) => { + if (!entry) return; + entry.kind = 'error'; + if (entry.tab instanceof HTMLElement) { + entry.tab.setAttribute('data-mnote-tab-kind', 'error'); + entry.tab.setAttribute('data-mnote-tab-badge-kind', 'file'); + entry.tab.classList.add('is-error'); + } + if (entry.panel instanceof HTMLElement) { + entry.panel.setAttribute('data-resource-kind', 'error'); + entry.panel.innerHTML = '

资源打开失败

无法加载此资源,请检查文件路径和访问权限。

'; + } + }; + + const createResourceTabDom = (input) => { + const paneRole = normalizePaneRole(input.paneRole); + const nodes = resourceTabHostNodes(paneRole); + if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null; + const objectIdentity = String(input.objectIdentity || '').trim(); + const registryKey = resourceTabRegistryKey(paneRole, objectIdentity); + const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源'; + const kind = normalizeResourceTabKind(input); + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 'mnote-main-tab'; + tab.setAttribute('role', 'tab'); + tab.setAttribute('data-mnote-main-tab', registryKey); + tab.setAttribute('data-mnote-object-identity', objectIdentity); + tab.setAttribute('data-pane-role', paneRole); + tab.setAttribute('data-mnote-tab-kind', kind); + tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind)); + tab.setAttribute('tabindex', '-1'); + tab.innerHTML = '×'; + const titleNode = tab.querySelector('.mnote-main-tab-title'); + if (titleNode) titleNode.textContent = title; + tab.addEventListener('click', (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) { + event.preventDefault(); + event.stopPropagation(); + closeResourceTab(registryKey); + return; + } + activateMainEditorTab(registryKey, paneRole); + }); + const panel = document.createElement('section'); + panel.className = 'mnote-resource-tab-panel'; + panel.setAttribute('data-mnote-resource-tab-panel', registryKey); + panel.setAttribute('data-mnote-object-identity', objectIdentity); + panel.setAttribute('data-pane-role', paneRole); + panel.setAttribute('data-resource-kind', kind); + panel.hidden = true; + nodes.strip.append(tab); + nodes.panelRoot.append(panel); + return { + objectIdentity, + registryKey, + paneRole, + title, + kind, + tab, + panel, + view: null, + session: null, + assetId: String(input.assetId || '').trim(), + path: String(input.path || '').trim(), + documentId: String(input.documentId || '').trim(), + ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(), + }; + }; + + const releaseResourceTabEntryRuntime = (entry) => { + if (!entry) return; + if (entry.view) { + unmountEditorViewBinding(entry.view, { releaseSession: true }); + entry.view = null; + entry.session = null; + } + if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { + try { + entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); + } catch (error) { + console.warn('mnote resource tab runtime unmount failed', error); + } + entry.mindmapRuntime = null; + } + if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren(); + }; + + const localResourceReadUrl = (rootUri, path) => { + const url = new URL('/api/local-folder/resource/read', window.location.origin); + url.searchParams.set('rootUri', rootUri || ''); + url.searchParams.set('path', path || ''); + return url.toString(); + }; + + const createResourceSession = (entry, input, readResult) => { + const resourcePath = String(input.path || ''); + const tiptapDocument = localizeTiptapAssetUrls( + toTiptapDocument(readResult?.content, readResult?.text || ''), + { + sourceKind: 'local_folder', + rootUri: String(input.rootUri || ''), + documentId: localMarkdownDocumentIdFromRelativePath(resourcePath), + } + ); + const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim(); + const session = { + key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`, + sessionKind: 'resource', + documentId: entry.objectIdentity, + ownerDocumentId: String(input.documentId || currentWebShellDocumentId() || '').trim(), + workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''), + sourceKind: 'local_folder', + rootUri: String(input.rootUri || ''), + resourcePath, + saveEndpoint: '/api/local-folder/resource/write', + pageAggregateScriptId: '', + latestAggregate: null, + title: entry.title, + currentTiptapDocument: tiptapDocument, + currentSerialized: JSON.stringify(tiptapDocument), + lastPersistedSerialized: JSON.stringify(tiptapDocument), + revision: null, + conflictDetectionKey, + fileVersion: conflictDetectionKey, + lastExternalConflictDetectionKey: conflictDetectionKey, + readOnly: false, + lastActiveAt: 0, + dirty: false, + saving: false, + hasExternalConflict: false, + externalChangePending: false, + externalRefreshSource: '', + lastExternalChangeSignalAt: 0, + lastSelfSaveSignalAt: 0, + lastExternalWriteSource: '', + lastExternalWriteRunId: '', + lastUserInputAt: 0, + saveTimer: 0, + externalRefreshTimer: 0, + releaseTimer: 0, + views: new Map(), + localFolderChannel: null, + status: 'ready', + error: null, + }; + documentSessionRegistry.set(session.key, session); + return session; + }; + + const openTiptapResourceTab = async (entry, input) => { + const runtime = await loadRuntime(); + const paneRole = normalizePaneRole(entry.paneRole); + entry.panel.innerHTML = `
`; + const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const observability = entry.panel.querySelector('[data-editor-host-observability]'); + if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing'); + const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`); + const readResult = payload.result || {}; + const session = createResourceSession(entry, input, readResult); + const runtimeDescriptor = { + paneRole, + root, + observability, + aggregate: { layout: { pageOptions: {} } }, + bootstrap: { + documentId: String(input.documentId || currentWebShellDocumentId() || '').trim(), + workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''), + sourceKind: 'local_folder', + rootUri: String(input.rootUri || ''), + saveEndpoint: '/api/local-folder/resource/write', + }, + }; + const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + const mountId = runtime.mount(root, { + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: false, + editable: true, + pageOptions: {}, + }); + view.mountId = mountId; + root.setAttribute('data-runtime-mount-id', String(mountId)); + root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource'); + root.setAttribute('data-document-id', session.ownerDocumentId || ''); + root.setAttribute('data-workspace-id', session.workspaceId || ''); + if (entry.panel instanceof HTMLElement) { + const shell = entry.panel.querySelector('.document-shell'); + if (shell instanceof HTMLElement) { + shell.setAttribute('data-document-id', session.ownerDocumentId || ''); + shell.setAttribute('data-workspace-id', session.workspaceId || ''); + } + } + setStatus(runtimeDescriptor, 'mounting-editor'); + entry.view = view; + entry.session = session; + markIntendedSlashRoot(entry); + }; + + const openPassiveResourceTab = (entry, input) => { + const href = String(input.officeUrl || input.href || '').trim(); + if (entry.kind === 'image') { + entry.panel.innerHTML = ''; + const img = entry.panel.querySelector('img'); + if (img instanceof HTMLImageElement) { + img.src = href; + img.alt = entry.title; + } + return; + } + entry.panel.innerHTML = ''; + const frame = entry.panel.querySelector('iframe'); + if (frame instanceof HTMLIFrameElement) { + frame.title = entry.title; + frame.src = href; + } + }; + + const refreshExistingOfficeResourceTab = (entry, input) => { + if (!entry || entry.kind !== 'office') return false; + const nextHref = String(input.officeUrl || input.href || '').trim(); + if (!nextHref) return false; + const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame'); + const currentHref = frame instanceof HTMLIFrameElement + ? String(frame.getAttribute('src') || frame.src || '').trim() + : ''; + if (currentHref !== nextHref) openPassiveResourceTab(entry, input); + return true; + }; + + const openMindmapResourceTab = async (entry, input) => { + const documentId = String(input.documentId || currentWebShellDocumentId() || '').trim(); + const mindmapId = String(input.mindmapId || input.assetId || '').trim(); + if (!documentId || !mindmapId) throw new Error('mindmap_resource_identity_missing'); + const targetUrl = new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin); + const runtime = await loadRuntime(); + const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl); + entry.title = String(input.title || title || '思维导图').trim() || '思维导图'; + const titleNode = entry.tab?.querySelector?.('.mnote-main-tab-title'); + if (titleNode) titleNode.textContent = entry.title; + entry.panel.innerHTML = '
'; + entry.panel.setAttribute('data-mnote-object-editor', 'mindmap'); + entry.panel.setAttribute('data-mnote-object-identity', entry.objectIdentity); + entry.panel.setAttribute('data-mnote-mindmap-id', mindmapId); + const root = entry.panel.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + if (!(root instanceof HTMLElement)) throw new Error('mindmap_resource_root_missing'); + root.setAttribute('data-mnote-object-editor', 'mindmap'); + root.setAttribute('data-mnote-object-identity', entry.objectIdentity); + root.setAttribute('data-mnote-mindmap-id', mindmapId); + root.setAttribute('data-document-id', documentId); + const mountId = runtime.mount(root, bootstrap); + root.setAttribute('data-runtime-mount-id', String(mountId)); + root.setAttribute('data-editor-host-kind', 'mindmap_resource_tab'); + root.setAttribute('data-runtime-editor-status', 'mounted'); + entry.view = null; + entry.session = null; + entry.mindmapRuntime = { runtime, mountId, root }; + }; + + const openUnsupportedSideTarget = (input = {}) => { + const url = currentUrl(); + secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); + const previousView = paneViewRegistry.get('secondary'); + if (previousView) { + unmountEditorViewBinding(previousView); + paneViewRegistry.delete('secondary'); + } + unmountMindmapPane('secondary'); + const workspace = document.querySelector('.mnote-document-workspace'); + if (workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'true'); + setSecondaryEditorHostVisible(true); + applyStoredSecondaryWidth(); + } + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; + const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); + if (pane instanceof HTMLElement) { + pane.hidden = false; + pane.setAttribute('data-pane-visible', 'true'); + pane.setAttribute('data-mnote-side-target', 'unsupported-resource'); + pane.removeAttribute('data-pane-document-id'); + pane.removeAttribute('data-pane-workspace-id'); + } + const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`); + const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源'; + if (root instanceof HTMLElement) { + root.replaceChildren(); + document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => { + if (node instanceof HTMLElement) node.remove(); + }); + root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target'); + root.setAttribute('data-mnote-side-target-unsupported', 'true'); + root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || '')); + const placeholder = document.createElement('div'); + placeholder.className = 'mnote-resource-tab-error'; + placeholder.setAttribute('data-mnote-side-target-placeholder', 'true'); + placeholder.innerHTML = '

暂不支持在侧栏打开此资源

'; + const text = placeholder.querySelector('p'); + if (text) text.textContent = title; + root.append(placeholder); + } + document.documentElement.setAttribute('data-mnote-side-target-unsupported', 'true'); + document.documentElement.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || '')); + replaceUrlState(url); + return true; + }; + + const openResourceInActiveTab = async (input = {}) => { + const paneRole = normalizePaneRole(input.paneRole || input.targetPaneRole || 'primary'); + if (paneRole === 'secondary') { + if (workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'true'); + applyStoredSecondaryWidth(); + } + setSecondaryEditorHostVisible(true); + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; + const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); + if (pane instanceof HTMLElement) { + pane.hidden = false; + pane.setAttribute('data-pane-visible', 'true'); + } + } + bindMainEditorPageTab(paneRole); + const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim(); + if (!objectIdentity) return false; + const registryKey = resourceTabRegistryKey(paneRole, objectIdentity); + if (paneRole === 'secondary') { + resourceTabRegistry.forEach((entry, key) => { + if (normalizePaneRole(entry.paneRole) !== paneRole) return; + if (key === registryKey) return; + releaseResourceTabEntryRuntime(entry); + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + removeFromResourceTabMru(paneRole, key); + }); + } + const existing = resourceTabRegistry.get(registryKey); + if (existing) { + refreshExistingOfficeResourceTab(existing, input); + activateMainEditorTab(registryKey, paneRole); + return true; + } + const entry = createResourceTabDom({ ...input, objectIdentity, paneRole }); + if (!entry) return false; + resourceTabRegistry.set(registryKey, entry); + activateMainEditorTab(registryKey, paneRole); + try { + if (entry.kind === 'mindmap') { + await openMindmapResourceTab(entry, input); + } else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') { + await openTiptapResourceTab(entry, input); + } else { + openPassiveResourceTab(entry, input); + } + activateMainEditorTab(registryKey, paneRole); + return true; + } catch (error) { + console.warn('mnote resource tab 打开失败', error); + markResourceTabError(entry); + return true; + } + }; + + const mountPane = async (runtimeDescriptor) => { + const runtime = await loadRuntime(); + const session = getOrCreateDocumentSession(runtimeDescriptor); + const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + + const mountOptions = { + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: session.readOnly, + editable: !session.readOnly, + pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {}, + }; + + setStatus(runtimeDescriptor, 'loading-assets'); + clearEmbeddedLocalDraft(runtimeDescriptor); + try { + const mountId = runtime.mount(runtimeDescriptor.root, mountOptions); + view.mountId = mountId; + runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); + runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); + setStatus(runtimeDescriptor, 'mounting-editor'); + paneViewRegistry.set(runtimeDescriptor.paneRole, view); + } catch (error) { + unmountEditorViewBinding(view); + throw error; + } + }; + + window.__mnoteDocumentPaneRuntime = { + openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { + const id = typeof documentId === 'string' ? documentId.trim() : ''; + if (!id) return false; + const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); + await replacePaneDocument('primary', descriptor); + activateMainEditorTab('', 'primary'); + updatePrimaryUrl(descriptor, url instanceof URL ? url : null); + return true; + }, + openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => { + const docId = typeof documentId === 'string' ? documentId.trim() : ''; + const mapId = typeof mindmapId === 'string' ? mindmapId.trim() : ''; + if (!docId || !mapId) return false; + await replacePrimaryPaneMindmap({ + documentId: docId, + mindmapId: mapId, + workspaceId: typeof workspaceId === 'string' ? workspaceId.trim() : '', + url, + }); + return true; + }, + openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { + const id = typeof documentId === 'string' ? documentId.trim() : ''; + if (!id) return false; + const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri }); + await replacePaneDocument('secondary', descriptor); + activateMainEditorTab('', 'secondary'); + if (url instanceof URL) replaceUrlState(url); + return true; + }, + resolveResourceOpen: (input) => resolveResourceOpen(input), + openResourceInActiveTab: openResourceInActiveTab, + activatePageTab: ({ paneRole } = {}) => { + const role = normalizePaneRole(paneRole || 'primary'); + bindMainEditorPageTab(role); + activateMainEditorTab('', role); + return true; + }, + openResourceAsSideTarget: async (input = {}) => { + return openResourceInActiveTab({ ...input, paneRole: 'secondary', openTarget: 'active-tab' }); + }, + closeSecondaryDocument: ({ url } = {}) => { + closeSecondaryPane(url instanceof URL ? url : currentUrl()); + return true; + }, + getOpenEditorsSnapshot: () => buildOpenEditorsSnapshot(), + refreshDocument: async ({ documentId, workspaceId, source } = {}) => { + const targets = Array.from(documentSessionRegistry.values()).filter((session) => ( + sessionMatchesDocumentWorkspace(session, documentId, workspaceId) + )); + await Promise.all(targets.map((session) => refreshSessionFromExternalChange( + session, + source || 'mnote-web-programmatic-refresh', + ))); + return targets.length; + }, + refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => { + const primary = paneViewRegistry.get('primary'); + if (!primary?.session) return 0; + if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0; + await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh'); + return 1; + }, + }; + + bindMainEditorPageTab('primary'); + bindMainEditorPageTab('secondary'); + + window.addEventListener('pagehide', () => { + Array.from(documentSessionRegistry.values()).forEach((session) => { + sessionViews(session).forEach((view) => { + unmountEditorViewBinding(view, { scheduleRelease: false }); + }); + releaseDocumentSession(session); + }); + }, { once: true }); + + if (paneRuntimes.length) { + Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => { + console.error('mnote multi-pane editor mount failed', error); + }); + } +})(); diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index ab549019..0c619aec 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -2802,7 +2802,13 @@ mod tests { assert!(html.contains(r#"data-testid="mnote-document-workspace""#)); assert!(html.contains(r#"data-mnote-resource-tab-host="true""#)); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); - assert!(html.contains("openResourceInActiveTab")); + assert!(html.contains( + r#""# + )); + assert!( + include_str!("../../browser/document-editor-adapter-runtime.js") + .contains("openResourceInActiveTab") + ); assert!(!html.contains(r#""# + r#""# } fn runtime_asset_root() -> PathBuf { @@ -4643,6 +855,20 @@ pub async fn document_conflict_panel_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn document_editor_adapter_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/document-editor-adapter-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 leptos_tiptap_manifest() -> Response { let manifest = json!({ "entryAssetPath": "mnote-leptos-tiptap-spike-island.js", @@ -5050,6 +1276,9 @@ mod tests { use serde_json::Value; use tower::util::ServiceExt; + const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str = + include_str!("../../browser/document-editor-adapter-runtime.js"); + fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -5240,6 +1469,7 @@ mod tests { .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); + let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; assert!(html.contains("data-testid=\"wolai-sidebar\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"workspace-shell\"")); assert!(html.contains("data-mnote-dev-fixture-kind=\"sidebar-tree\"")); @@ -5270,8 +1500,8 @@ mod tests { assert!(html.contains( r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"# )); - assert!(html.contains("row.getAttribute('data-row-id') === `doc:${documentId}`")); - assert!(!html.contains("row.getAttribute('data-row-id') === `index:${documentId}`")); + assert!(runtime.contains("row.getAttribute('data-row-id') === `doc:${documentId}`")); + assert!(!runtime.contains("row.getAttribute('data-row-id') === `index:${documentId}`")); assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title")); assert!(html.contains("data-testid=\"wolai-page-settings-trigger\"")); assert!(html.contains("data-mnote-action=\"open-page-settings\"")); @@ -5285,97 +1515,99 @@ mod tests { assert!(html.contains("class=\"mnote-main-tab-badge\"")); assert!(!html.contains(">description"#)); + let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; + assert!(runtime.contains("resourceTabBadgeKind(input, kind)")); + assert!(runtime.contains("mnote.open_editors_snapshot.v1")); + assert!(runtime.contains("getOpenEditorsSnapshot")); + assert!(runtime.contains("bindMainEditorTabStrip")); + assert!(runtime.contains("data-mnote-tab-strip-bound")); + assert!(runtime.contains("currentWebShellDocumentId")); + assert!(runtime.contains( "const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();" )); - assert!(html.contains( + assert!(runtime.contains( "if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;" )); assert!(!html .contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()")); - assert!(html.contains("runtime.default({ module_or_path: wasmUrl })")); - assert!(html.contains("positionSlashMenuForRoot")); - assert!(html.contains("setSlashMenuStyle(menu, 'position', 'fixed');")); - assert!(html.contains("installGlobalSlashMenuPositioning();")); - assert!(html.contains("data-mnote-slash-positioned', 'host'")); - assert!(html.contains( + assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })")); + assert!(runtime.contains("positionSlashMenuForRoot")); + assert!(runtime.contains("setSlashMenuStyle(menu, 'position', 'fixed');")); + assert!(runtime.contains("installGlobalSlashMenuPositioning();")); + assert!(runtime.contains("data-mnote-slash-positioned', 'host'")); + assert!(runtime.contains( "const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');" )); - assert!(html.contains("const setSlashMenuInactive = (menu, inactive) =>")); - assert!(html.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>")); - assert!(html.contains("data-mnote-slash-inactive")); - assert!(html.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")); - assert!(!html.contains( + assert!(runtime.contains("const setSlashMenuInactive = (menu, inactive) =>")); + assert!(runtime.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>")); + assert!(runtime.contains("data-mnote-slash-inactive")); + assert!(runtime.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")); + assert!(!runtime.contains( "|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')" )); - assert!(html.contains("data-mnote-side-target-unsupported') !== 'true'")); + assert!(runtime.contains("data-mnote-side-target-unsupported') !== 'true'")); assert!( - html.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);") + runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);") ); - assert!(html.contains("data-mnote-resource-tab-host")); - assert!(html.contains("openPrimaryMindmap")); - assert!(html.contains("openResourceInActiveTab")); - assert!(html.contains("/api/local-folder/resource/read")); - assert!(html.contains("/api/local-folder/resource/write")); - assert!(html.contains("replacePrimaryPaneMindmap")); - assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__")); - assert!(html.contains("data-mnote-object-identity")); - assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); + assert!(runtime.contains("data-mnote-resource-tab-host")); + assert!(runtime.contains("openPrimaryMindmap")); + assert!(runtime.contains("openResourceInActiveTab")); + assert!(runtime.contains("/api/local-folder/resource/read")); + assert!(runtime.contains("/api/local-folder/resource/write")); + assert!(runtime.contains("replacePrimaryPaneMindmap")); + assert!(runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__")); + assert!(runtime.contains("data-mnote-object-identity")); + assert!(runtime.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__")); assert!(html.contains("mnote.tree_live_bootstrap.v1")); assert!(html.contains("/api/tree/events")); assert!(html.contains("/api/realtime/ws")); - assert!(html.contains("syncPageAggregateScript(session, nextAggregate);")); - assert!(html.contains( + assert!(runtime.contains("syncPageAggregateScript(session, nextAggregate);")); + assert!(runtime.contains( "const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {" )); - assert!(html.contains("body?.blockDocument || body?.block_document")); - assert!(html.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")); - assert!(html + assert!(runtime.contains("body?.blockDocument || body?.block_document")); + assert!(runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")); + assert!(runtime .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);")); - assert!(html.contains( + assert!(runtime.contains( "const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);" )); - assert!(html.contains("mnote.localFolder.selfChangeSuppressions.v1")); - assert!(html.contains("ensureLocalFolderSelfChangeSuppressions()")); - assert!(html.contains("markLocalFolderSelfChangeSuppression(session);")); - assert!(html.contains("const suppressibleSelfWrite = kind.includes('Create')")); - assert!(html.contains("|| kind.includes('Modify(Data')")); - assert!(html.contains("|| kind.includes('Modify(Any')")); - assert!(!html.contains("mnote-web-document-shell")); + assert!(runtime.contains("mnote.localFolder.selfChangeSuppressions.v1")); + assert!(runtime.contains("ensureLocalFolderSelfChangeSuppressions()")); + assert!(runtime.contains("markLocalFolderSelfChangeSuppression(session);")); + assert!(runtime.contains("const suppressibleSelfWrite = kind.includes('Create')")); + assert!(runtime.contains("|| kind.includes('Modify(Data')")); + assert!(runtime.contains("|| kind.includes('Modify(Any')")); + assert!(!runtime.contains("mnote-web-document-shell")); // Resource open resolver contract - assert!(html.contains("normalizeResourceTabKind")); - assert!(html.contains("data-resource-tab-error")); - assert!(html.contains("resourceTabCloseGuardReason")); - assert!(html.contains("closeResourceTab")); - assert!(html.contains("resourceTabRegistry.delete(key)")); - assert!(html.contains("data-testid=\"mnote-secondary-editor-tab-host\"")); + assert!(runtime.contains("normalizeResourceTabKind")); + assert!(runtime.contains("data-resource-tab-error")); + assert!(runtime.contains("resourceTabCloseGuardReason")); + assert!(runtime.contains("closeResourceTab")); + assert!(runtime.contains("resourceTabRegistry.delete(key)")); + assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\"")); assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\"")); - assert!(html.contains("resourceTabRegistryKey(paneRole, objectIdentity)")); - assert!(html.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'")); - assert!(html.contains("data-mnote-editor-kind=\"resource\"")); - assert!(html.contains("const paneRole = normalizePaneRole(entry.paneRole);")); - assert!(html.contains("paneRole,")); - assert!(html.contains("markIntendedSlashRoot(entry);")); - assert!(html.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);")); - assert!(html.contains("const nextKey = lastActiveResourceTabKey(role);")); - assert!(html.contains("if (nextTab instanceof HTMLElement) nextTab.focus();")); - assert!(html.contains("resourceTabBadgeKind(input, kind)")); - assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")); - assert!(html.contains("if (!entry || entry.kind !== 'office') return false;")); + assert!(runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)")); + assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'")); + assert!(runtime.contains("data-mnote-editor-kind=\"resource\"")); + assert!(runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);")); + assert!(runtime.contains("paneRole,")); + assert!(runtime.contains("markIntendedSlashRoot(entry);")); + assert!(runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);")); + assert!(runtime.contains("const nextKey = lastActiveResourceTabKey(role);")); + assert!(runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();")); + assert!(runtime.contains("resourceTabBadgeKind(input, kind)")); + assert!(runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")); + assert!(runtime.contains("if (!entry || entry.kind !== 'office') return false;")); assert!( - html.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);") + runtime.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);") ); - assert!(html.contains("refreshExistingOfficeResourceTab(existing, input);")); + assert!(runtime.contains("refreshExistingOfficeResourceTab(existing, input);")); } #[tokio::test] @@ -5698,43 +1930,44 @@ mod tests { assert!(html.contains("data-page-openable=\"false\"")); assert!(html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js")); assert!(html.contains("/api/mnote-browser-runtime/filetree-runtime.js")); + assert!(html.contains(r#""#)); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); - assert!(html.contains("refreshSessionFromExternalFileChange")); - assert!(html.contains("refreshSessionFromExternalChange")); - assert!(html.contains("treeExternalConflictMessage")); - assert!(html.contains("tree:delta")); - assert!(html.contains("tree:resync")); - assert!(html.contains("mnote-web-tree-live")); - assert!(html.contains("refreshMindmapRuntimesFromTreePayload")); - assert!(html.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); - assert!(html.contains("/api/local-folder/events")); - assert!(html.contains("localFolderEventChannelKey")); - assert!(html.contains("url.searchParams.set('documentId', session.documentId);")); - assert!(html.contains("if (!documentId) return;")); - assert!(html.contains("new EventSource(url.toString())")); - assert!(html.contains("localFolderEventRegistry")); - assert!(html + let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; + assert!(runtime.contains("refreshSessionFromExternalFileChange")); + assert!(runtime.contains("refreshSessionFromExternalChange")); + assert!(runtime.contains("treeExternalConflictMessage")); + assert!(runtime.contains("tree:delta")); + assert!(runtime.contains("tree:resync")); + assert!(runtime.contains("mnote-web-tree-live")); + assert!(runtime.contains("refreshMindmapRuntimesFromTreePayload")); + assert!(runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); + assert!(runtime.contains("/api/local-folder/events")); + assert!(runtime.contains("localFolderEventChannelKey")); + assert!(runtime.contains("url.searchParams.set('documentId', session.documentId);")); + assert!(runtime.contains("if (!documentId) return;")); + assert!(runtime.contains("new EventSource(url.toString())")); + assert!(runtime.contains("localFolderEventRegistry")); + assert!(runtime .contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')")); - assert!(html.contains("shouldSuppressLocalFolderSelfChange")); - assert!(html.contains("kind.includes('Modify(Name')")); - assert!(html.contains("targetSession.views.size === 0")); - assert!(html.contains("session.views.size === 0")); - assert!(html.contains("targetSession.saving")); - assert!(html.contains("lastSelfSaveSignalAt")); - assert!(html.contains("clearSessionConflictSurface(view.session);")); - assert!(html.contains("command: 'replaceContent'")); - assert!(html.contains("external-change-conflict")); - assert!(html.contains("mnote-editor-conflict-panel")); - assert!(html.contains("mnote-conflict-accept-disk")); - assert!(html.contains("mnote-conflict-keep-current")); - assert!(html.contains("mnote-conflict-open-diff")); - assert!(html.contains("mnote-conflict-merge-text")); - assert!(html.contains("mnote-conflict-merge-save")); - assert!( - html.contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)") - ); - assert!(html.contains("agent run")); - assert!(!html.contains( + assert!(runtime.contains("shouldSuppressLocalFolderSelfChange")); + assert!(runtime.contains("kind.includes('Modify(Name')")); + assert!(runtime.contains("targetSession.views.size === 0")); + assert!(runtime.contains("session.views.size === 0")); + assert!(runtime.contains("targetSession.saving")); + assert!(runtime.contains("lastSelfSaveSignalAt")); + assert!(runtime.contains("clearSessionConflictSurface(view.session);")); + assert!(runtime.contains("command: 'replaceContent'")); + assert!(runtime.contains("external-change-conflict")); + assert!(runtime.contains("mnote-editor-conflict-panel")); + assert!(runtime.contains("mnote-conflict-accept-disk")); + assert!(runtime.contains("mnote-conflict-keep-current")); + assert!(runtime.contains("mnote-conflict-open-diff")); + assert!(runtime.contains("mnote-conflict-merge-text")); + assert!(runtime.contains("mnote-conflict-merge-save")); + assert!(runtime + .contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)")); + assert!(runtime.contains("agent run")); + assert!(!runtime.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" )); } @@ -5784,6 +2017,16 @@ mod tests { .contains("writeMergedConflictResult: writeMergedConflictResult")); } + #[test] + fn document_editor_adapter_runtime_contains_host_contracts() { + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("PANES_BOOTSTRAP_ID")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("ROOT_SELECTOR")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("BRIDGE_PROTOCOL")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("legacyInlineContentToTiptap")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap")); + } + #[tokio::test] async fn sidebar_and_filetree_do_not_return_dev_fixtures_by_default() { let config = AppConfig { @@ -5922,32 +2165,34 @@ mod tests { .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); - assert!(html.contains("legacyInlineContentToTiptap")); - assert!(html.contains("legacyStylesToTiptapMarks")); - assert!(html.contains("legacyMarkArrayToTiptapMarks")); - assert!(html.contains("firstNonEmptyText(block?.props?.sourcePath")); - assert!(html.contains("marks.push({ type: 'bold' })")); - assert!(html.contains("marks.push({ type: 'italic' })")); - assert!(html.contains("marks.push({ type: 'underline' })")); - assert!(html.contains("marks.push({ type: 'strike' })")); - assert!(html.contains("marks.push({ type: 'code' })")); - assert!(html.contains("marks.push({ type: 'link', attrs: { href } })")); - assert!(html.contains("styles.link = href")); - assert!(html.contains("contentNodes.map((node) => {")); - assert!(html.contains("payload: { type: 'text', text")); - assert!(html.contains("typeof payload.text === 'string'")); - assert!(html.contains("payload.type === 'hard_break'")); - assert!(html.contains("typeof body?.fileVersion === 'string'")); - assert!(html.contains("expectedFileVersion: session.conflictDetectionKey")); - assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); - assert!(html.contains("blockType: 'mindmap'")); - assert!(html.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)")); - assert!(html.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)")); - assert!(html.contains("mnoteBlockType: 'mindmap'")); - assert!(html.contains("block.blockType === 'mindmap'")); - assert!(html.contains("content: block.blockType === 'mindmap'")); - assert!(html.contains("? ''")); - assert!(!html.contains( + assert!(html.contains(r#""#)); + let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; + assert!(runtime.contains("legacyInlineContentToTiptap")); + assert!(runtime.contains("legacyStylesToTiptapMarks")); + assert!(runtime.contains("legacyMarkArrayToTiptapMarks")); + assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath")); + assert!(runtime.contains("marks.push({ type: 'bold' })")); + assert!(runtime.contains("marks.push({ type: 'italic' })")); + assert!(runtime.contains("marks.push({ type: 'underline' })")); + assert!(runtime.contains("marks.push({ type: 'strike' })")); + assert!(runtime.contains("marks.push({ type: 'code' })")); + assert!(runtime.contains("marks.push({ type: 'link', attrs: { href } })")); + assert!(runtime.contains("styles.link = href")); + assert!(runtime.contains("contentNodes.map((node) => {")); + assert!(runtime.contains("payload: { type: 'text', text")); + assert!(runtime.contains("typeof payload.text === 'string'")); + assert!(runtime.contains("payload.type === 'hard_break'")); + assert!(runtime.contains("typeof body?.fileVersion === 'string'")); + assert!(runtime.contains("expectedFileVersion: session.conflictDetectionKey")); + assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); + assert!(runtime.contains("blockType: 'mindmap'")); + assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)")); + assert!(runtime.contains("mindmapPropsFromAttrs(block.props || {}, block.blockId)")); + assert!(runtime.contains("mnoteBlockType: 'mindmap'")); + assert!(runtime.contains("block.blockType === 'mindmap'")); + assert!(runtime.contains("content: block.blockType === 'mindmap'")); + assert!(runtime.contains("? ''")); + assert!(!runtime.contains( "block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')" )); }