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 6e1f234e..30d7d693 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 @@ -121,7 +121,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tests::mno - [x] B1. `document-pane-runtime.js`:secondary pane URL、pane resize、pane close、pane runtime registry。 - [x] B2. `document-tiptap-conversion-runtime.js`:legacy block / inline content / marks 转 Tiptap document。 -- [ ] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。 +- [x] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。 - [x] B4. `document-mindmap-host-runtime.js`:primary mindmap object shell、mindmap resource tab mount/unmount。 - [x] B5. `document-slash-position-runtime.js`:slash menu active root、positioning、mutation observer。 - [ ] B6. entrypoint 只负责读取 bootstrap、加载 runtime、装配各子模块。 diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index c0929fec..0118aeb8 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -1,4 +1,5 @@ import { createMindmapHostRuntime } from './document-mindmap-host-runtime.js'; +import { createResourceTabRuntime } from './document-resource-tab-runtime.js'; import { installGlobalSlashMenuPositioning, markIntendedSlashRoot, @@ -170,10 +171,22 @@ import { const documentSessionRegistry = new Map(); const localFolderEventRegistry = new Map(); const paneViewRegistry = new Map(); - const resourceTabRegistry = new Map(); - const resourceTabMru = { primary: [], secondary: [] }; - const resourceTabMruMax = 20; - const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded'; + 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 ''; + }; + let nextViewId = 1; const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; @@ -188,6 +201,11 @@ import { currentDocumentId: () => currentWebShellDocumentId(), }); + let resourceTabs = null; + const syncResourceSessionTabGuards = (session) => { + if (resourceTabs) resourceTabs.syncResourceSessionTabGuards(session); + }; + const ensureLocalFolderSelfChangeSuppressions = () => { const now = Date.now(); const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map @@ -1694,21 +1712,7 @@ import { 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 (resourceTabs) resourceTabs.closeResourceTabsForPane('secondary'); if (workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'false'); workspace.style.removeProperty('grid-template-columns'); @@ -1900,742 +1904,34 @@ import { 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(), + resourceTabs = createResourceTabRuntime({ + loadRuntime, + createEditorViewBinding, + unmountEditorViewBinding, + setStatus, + documentSessionRegistry, + sessionHasRecentLocalInput, + currentUrl, + replaceUrlState, + applyStoredSecondaryWidth, + workspace, + setSecondaryEditorHostVisible, + markIntendedSlashRoot, + toTiptapDocument, + openMindmapResourceTab: (...args) => mindmapHost.openMindmapResourceTab(...args), + unmountMindmapPane: (...args) => mindmapHost.unmountMindmapPane(...args), + paneViewRegistry, + rootSelector: ROOT_SELECTOR, + currentDocumentId: () => currentWebShellDocumentId(), + currentWorkspaceId: () => currentWebShellWorkspaceId(), + secondaryQueryParamNames, }); - - 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 openUnsupportedSideTarget = (input = {}) => { - const url = currentUrl(); - secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); - const previousView = paneViewRegistry.get('secondary'); - if (previousView) { - unmountEditorViewBinding(previousView); - paneViewRegistry.delete('secondary'); - } - mindmapHost.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 mindmapHost.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 normalizePaneRole = (...args) => resourceTabs.normalizePaneRole(...args); + const resolveResourceOpen = (...args) => resourceTabs.resolveResourceOpen(...args); + const openResourceInActiveTab = (...args) => resourceTabs.openResourceInActiveTab(...args); + const activateMainEditorTab = (...args) => resourceTabs.activateMainEditorTab(...args); + const bindMainEditorPageTab = (...args) => resourceTabs.bindMainEditorPageTab(...args); + const buildOpenEditorsSnapshot = (...args) => resourceTabs.buildOpenEditorsSnapshot(...args); const mountPane = async (runtimeDescriptor) => { const runtime = await loadRuntime(); diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js new file mode 100644 index 00000000..b4a5b47b --- /dev/null +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -0,0 +1,800 @@ +import { + localMarkdownDocumentIdFromRelativePath, + localizeTiptapAssetUrls, +} from './document-tiptap-conversion-runtime.js'; + +export const createResourceTabRuntime = (dependencies = {}) => { + const { + loadRuntime, + createEditorViewBinding, + unmountEditorViewBinding, + setStatus, + documentSessionRegistry, + sessionHasRecentLocalInput, + currentUrl, + replaceUrlState, + applyStoredSecondaryWidth, + workspace, + setSecondaryEditorHostVisible, + markIntendedSlashRoot, + toTiptapDocument, + openMindmapResourceTab, + unmountMindmapPane, + paneViewRegistry, + rootSelector, + currentDocumentId, + currentWorkspaceId, + secondaryQueryParamNames = [], + } = dependencies; + const selector = rootSelector || '[data-testid="mnote-leptos-tiptap-island-editor-root"]'; + const resourceTabRegistry = new Map(); + const resourceTabMru = { primary: [], secondary: [] }; + const resourceTabMruMax = 20; + const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded'; + + 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 = () => { + const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : ''; + if (explicit) return explicit; + try { + return currentUrl().searchParams.get('workspaceId') || ''; + } catch (_) { + return ''; + } + }; + + const currentWebShellDocumentId = () => { + const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : ''; + if (explicit) return explicit; + 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 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(`${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 closeResourceTabsForPane = (paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => { + if (normalizePaneRole(entry?.paneRole) !== role) return; + releaseResourceTabEntryRuntime(entry); + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + removeFromResourceTabMru(role, key); + }); + syncOpenEditorsSnapshot(); + }; + + return { + activateMainEditorTab, + bindMainEditorPageTab, + buildOpenEditorsSnapshot, + closeResourceTabsForPane, + normalizePaneRole, + openResourceInActiveTab, + resolveResourceOpen, + syncResourceSessionTabGuards, + }; +}; diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 0c619aec..509cc830 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -2806,7 +2806,7 @@ mod tests { r#""# )); assert!( - include_str!("../../browser/document-editor-adapter-runtime.js") + include_str!("../../browser/document-resource-tab-runtime.js") .contains("openResourceInActiveTab") ); assert!(!html.contains(r#""#)); - let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; - assert!(runtime.contains("resourceTabBadgeKind(input, kind)")); - assert!(runtime.contains("mnote.open_editors_snapshot.v1")); + let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS; + assert!(runtime.contains("document-resource-tab-runtime.js")); + assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)")); + assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1")); assert!(runtime.contains("getOpenEditorsSnapshot")); - assert!(runtime.contains("bindMainEditorTabStrip")); - assert!(runtime.contains("data-mnote-tab-strip-bound")); + assert!(resource_runtime.contains("bindMainEditorTabStrip")); + assert!(resource_runtime.contains("data-mnote-tab-strip-bound")); assert!(runtime.contains("currentWebShellDocumentId")); - assert!(runtime.contains( + assert!(resource_runtime.contains( "const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();" )); - assert!(runtime.contains( + assert!(resource_runtime.contains( "if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;" )); assert!(!html @@ -1618,11 +1635,11 @@ mod tests { assert!( runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);") ); - assert!(runtime.contains("data-mnote-resource-tab-host")); + assert!(html.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!(resource_runtime.contains("/api/local-folder/resource/read")); + assert!(resource_runtime.contains("/api/local-folder/resource/write")); let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS; assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap")); assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__")); @@ -1655,29 +1672,30 @@ mod tests { assert!(!runtime.contains("mnote-web-document-shell")); // Resource open resolver contract - 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!(resource_runtime.contains("normalizeResourceTabKind")); + assert!(resource_runtime.contains("data-resource-tab-error")); + assert!(resource_runtime.contains("resourceTabCloseGuardReason")); + assert!(resource_runtime.contains("closeResourceTab")); + assert!(resource_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!(runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)")); + assert!(resource_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!(resource_runtime.contains("data-mnote-editor-kind=\"resource\"")); + assert!(resource_runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);")); + assert!(resource_runtime.contains("paneRole,")); + assert!(resource_runtime.contains("markIntendedSlashRoot(entry);")); + assert!(resource_runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);")); + assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);")); + assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();")); + assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)")); assert!( - runtime.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);") + resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>") ); - assert!(runtime.contains("refreshExistingOfficeResourceTab(existing, input);")); + assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;")); + assert!(resource_runtime + .contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);")); + assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);")); } #[tokio::test] @@ -2098,6 +2116,10 @@ mod tests { assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js")); assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane")); assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-resource-tab-runtime.js")); + assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab")); + assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot")); + assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js")); assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition")); assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition"));