import { localFileOpenPathFromTiptapHref, localMarkdownDocumentIdFromRelativePath, localMarkdownRelativePathFromDocumentId, localizeTiptapAssetUrls, } from './document-tiptap-conversion-runtime.js'; /** * @typedef {Object} MNoteWorkspacePath * @property {'mnote.workspace_path.v1'} schema * @property {string} workspaceId * @property {string} sourceKind * @property {string} rootUri * @property {string} relativePath * @property {string} documentId * @property {string} objectIdentity * @property {string} assetId * @property {string} resourceKind * * @typedef {Object} MNoteOpenEditorEntry * @property {string} objectIdentity * @property {MNoteWorkspacePath|null} workspacePath * @property {'primary'|'secondary'} paneRole * @property {string} editorKind * @property {boolean} active * @property {string} dirtyState * @property {boolean} preview * @property {boolean} pinned * @property {number} lastActiveAt * * @typedef {Object} MNoteOpenEditorsSnapshot * @property {'mnote.open_editors_snapshot.v1'} schema * @property {number} generatedAt * @property {string} activeObjectIdentity * @property {MNoteOpenEditorEntry[]} editors * @property {{primary: Object, secondary: Object}} groups */ export const createResourceTabRuntime = (dependencies = {}) => { const { loadRuntime, createEditorViewBinding, ensureLocalFolderEventChannel, 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 readonlyCodePreviewMaxChars = 1_500_000; const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded'; let onlyofficeBridgeReadyListenerBound = false; 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 resourceTabWidthType = (entry) => { if (!entry) return ''; const kind = String(entry.kind || '').trim(); if (kind === 'office') { const badgeKind = String(entry.badgeKind || resourceTabBadgeKind(entry, kind) || '').trim(); if (badgeKind === 'ppt') return 'ppt'; if (badgeKind === 'sheet') return 'excel'; return 'word'; } if (kind === 'pdf') return 'pdf'; if (kind === 'mindmap') return 'mindmap'; if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'markdown'; return ''; }; const currentWebShellWorkspaceId = () => { const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : ''; if (explicit) return explicit; const fromBody = document.body?.dataset?.workspaceId || ''; if (fromBody) return String(fromBody).trim(); const shell = document.querySelector('.document-shell[data-workspace-id], [data-document-pane="true"][data-pane-role="primary"][data-pane-workspace-id]'); if (shell instanceof HTMLElement) { const value = shell.getAttribute('data-workspace-id') || shell.getAttribute('data-pane-workspace-id') || ''; if (value) return value.trim(); } try { return currentUrl().searchParams.get('workspaceId') || ''; } catch (_) { return ''; } }; const currentFileTreeWorkspacePath = () => { try { const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path]'); const reader = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow; if (row instanceof HTMLElement && typeof reader === 'function') { const workspacePath = reader(row); if (workspacePath && typeof workspacePath === 'object') { return { ...workspacePath, sourceKind: String(workspacePath.sourceKind || row.getAttribute('data-source-kind') || '').trim(), rootUri: String(workspacePath.rootUri || row.getAttribute('data-root-uri') || '').trim(), }; } } if (row instanceof HTMLElement) { return { sourceKind: String(row.getAttribute('data-source-kind') || '').trim(), rootUri: String(row.getAttribute('data-root-uri') || '').trim(), }; } } catch (_) {} return null; }; 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 documentIdForPane = (paneRole) => { const role = normalizePaneRole(paneRole); const nodes = resourceTabHostNodes(role); const fromTab = String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(); if (fromTab) return fromTab; const pane = document.querySelector(`[data-document-pane="true"][data-pane-role="${role}"]`); if (pane instanceof HTMLElement) { const fromPane = String(pane.getAttribute('data-pane-document-id') || '').trim(); if (fromPane) return fromPane; const root = pane.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-document-id], .document-shell[data-document-id]'); if (root instanceof HTMLElement) { const fromRoot = String(root.getAttribute('data-document-id') || '').trim(); if (fromRoot) return fromRoot; } } return role === 'primary' ? currentWebShellDocumentId() : ''; }; const currentWebShellSourceKind = () => { try { return currentUrl().searchParams.get('sourceKind') || document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || ''; } catch (_) { return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || ''; } }; const currentWebShellRootUri = () => { try { return currentUrl().searchParams.get('rootUri') || document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || ''; } catch (_) { return document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || ''; } }; const buildWorkspacePath = ({ workspacePath, workspaceId, sourceKind, rootUri, relativePath, documentId, objectIdentity, assetId, resourceKind, } = {}) => { const fromInput = workspacePath && typeof workspacePath === 'object' ? workspacePath : null; if (fromInput && String(fromInput.schema || '') === 'mnote.workspace_path.v1') { return { schema: 'mnote.workspace_path.v1', workspaceId: String(fromInput.workspaceId || workspaceId || '').trim(), sourceKind: String(fromInput.sourceKind || sourceKind || '').trim(), rootUri: String(fromInput.rootUri || rootUri || '').trim(), relativePath: String(fromInput.relativePath || fromInput.localRelativePath || relativePath || '').trim(), documentId: String(fromInput.documentId || documentId || '').trim(), objectIdentity: fromInput.objectIdentity ?? String(objectIdentity || '').trim(), assetId: String(fromInput.assetId || assetId || '').trim(), resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(), }; } const structuredObjectIdentity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : null; const objectIdentityText = structuredObjectIdentity ? '' : String(objectIdentity || '').trim(); const id = String(objectIdentityText || documentId || assetId || relativePath || '').trim(); if (!id) return null; return { schema: 'mnote.workspace_path.v1', workspaceId: String(workspaceId || '').trim(), sourceKind: String(sourceKind || '').trim(), rootUri: String(rootUri || '').trim(), relativePath: String(relativePath || '').trim(), documentId: String(documentId || '').trim(), objectIdentity: structuredObjectIdentity || objectIdentityText, assetId: String(assetId || '').trim(), resourceKind: String(resourceKind || '').trim(), }; }; 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'; 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) return; const timestamp = Date.now(); entry.lastActiveAt = timestamp; if (entry.session) entry.session.lastActiveAt = timestamp; }; 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 bufferDirtyState = String(session.bufferDirtyState || '').trim(); if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') { return bufferDirtyState; } 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 documentSessionForPane = (paneRole, documentId) => { const id = String(documentId || '').trim(); if (!id || !documentSessionRegistry || typeof documentSessionRegistry.values !== 'function') return null; const role = normalizePaneRole(paneRole); return Array.from(documentSessionRegistry.values()).find((session) => ( session && session.sessionKind !== 'resource' && String(session.documentId || '').trim() === id && Array.from(session.views?.values?.() || []).some((view) => normalizePaneRole(view?.runtimeDescriptor?.paneRole) === role) )) || Array.from(documentSessionRegistry.values()).find((session) => ( session && session.sessionKind !== 'resource' && String(session.documentId || '').trim() === id )) || null; }; const documentSessionDirtyState = (session) => { if (!session) return ''; const bufferDirtyState = String(session.bufferDirtyState || '').trim(); if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') return bufferDirtyState; if (session.hasExternalConflict) return 'ExternalModified'; if (session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session)) return 'Dirty'; if (session.saving) return 'Saving'; return ''; }; const resourceTabBufferStateUrl = (entry) => { const session = entry?.session; if (!session || session.sourceKind !== 'local_folder') return null; const relativePath = String(session.resourcePath || entry?.workspacePath?.relativePath || '').trim(); const rootUri = String(session.rootUri || entry?.workspacePath?.rootUri || '').trim(); if (!relativePath || !rootUri) return null; const url = new URL('/api/documents/buffer-state', window.location.origin); url.searchParams.set('documentId', String(session.documentId || entry.objectIdentity || '')); url.searchParams.set('sourceKind', 'local_folder'); url.searchParams.set('rootUri', rootUri); url.searchParams.set('relativePath', relativePath); const workspaceId = String(session.workspaceId || entry?.workspacePath?.workspaceId || '').trim(); if (workspaceId) url.searchParams.set('workspaceId', workspaceId); return url; }; const applyResourceTabCloseGuard = (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 refreshResourceTabBufferState = async (entry) => { const url = resourceTabBufferStateUrl(entry); if (!url || !entry?.session) return null; try { 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 || !payload.result) return null; const dirtyState = String(payload.result.dirtyState || '').trim(); if (dirtyState) entry.session.bufferDirtyState = dirtyState; if (typeof payload.result.fileVersion === 'string' && payload.result.fileVersion.trim()) { entry.session.fileVersion = payload.result.fileVersion.trim(); } applyResourceTabCloseGuard(entry); return payload.result; } catch (_) { return null; } }; const syncResourceTabCloseGuard = (entry) => { applyResourceTabCloseGuard(entry); void refreshResourceTabBufferState(entry); }; const syncResourceSessionTabGuards = (session) => { if (!session || session.sessionKind !== 'resource') return; resourceTabRegistry.forEach((entry) => { if (entry.session === session) syncResourceTabCloseGuard(entry); }); }; const officeBridgeDebugForEntry = (entry) => { if (!entry?.panel || !(entry.panel instanceof HTMLElement)) return null; const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame'); if (!(frame instanceof HTMLIFrameElement)) return null; try { const debug = frame.contentWindow?.__MNOTE_ONLYOFFICE_DEBUG__; return debug && typeof debug === 'object' ? debug : null; } catch (_) { return null; } }; const officeOpenModeForEntry = (entry) => { if (!entry || normalizeResourceTabKind(entry) !== 'office') return ''; const href = String(entry.passiveFrameSrc || entry.officeUrl || entry.href || '').trim() || String(entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame')?.getAttribute?.('src') || '').trim(); if (href) { try { const url = new URL(href, window.location.origin); if (url.pathname === '/office-preview' || url.pathname === '/office-n') return 'preview'; if (url.pathname === '/onlyoffice' || url.pathname.startsWith('/office/')) return 'onlyoffice_live'; } catch (_) {} } if (entry.onlyofficeSessionId || entry.bridgeSessionId) return 'onlyoffice_live'; return 'preview'; }; const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => { const active = entry?.tab instanceof HTMLElement ? entry.tab.getAttribute('aria-selected') === 'true' : false; const documentId = String(entry?.documentId || entry?.ownerDocumentId || entry?.session?.ownerDocumentId || '').trim(); const workspaceId = String(entry?.workspaceId || entry?.session?.workspaceId || currentWebShellWorkspaceId() || '').trim(); const sourceKind = String(entry?.sourceKind || entry?.session?.sourceKind || currentWebShellSourceKind() || '').trim(); const rootUri = String(entry?.rootUri || entry?.session?.rootUri || currentWebShellRootUri() || '').trim(); const relativePath = String(entry?.path || entry?.session?.resourcePath || '').trim(); const objectIdentity = String(entry?.objectIdentity || key || '').trim(); const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim(); const kind = normalizeResourceTabKind(entry); const dirtyState = resourceTabCloseGuardReason(entry?.session); const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null; const officeOpenMode = kind === 'office' ? officeOpenModeForEntry(entry) : ''; const onlyofficeSessionId = String( officeBridgeDebug?.bridgeSessionId || entry?.onlyofficeSessionId || entry?.bridgeSessionId || '', ).trim(); const snapshotResourceKind = kind === 'office' ? (officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment') : kind; const workspacePathSeed = entry?.workspacePath && typeof entry.workspacePath === 'object' ? { ...entry.workspacePath, resourceKind: snapshotResourceKind } : entry?.workspacePath; return { objectIdentity, workspacePath: buildWorkspacePath({ workspacePath: workspacePathSeed, workspaceId, sourceKind, rootUri, relativePath, documentId, objectIdentity, assetId, resourceKind: snapshotResourceKind, }), paneRole: normalizePaneRole(entry?.paneRole), documentId, workspaceId, title: String(entry?.title || '资源').trim() || '资源', kind, editorKind: kind, badgeKind: entry?.tab instanceof HTMLElement ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim() : resourceTabBadgeKind(entry, entry?.kind), active, dirtyState, dirtyGuard: dirtyState, assetId, path: relativePath, officeOpenMode, onlyofficeSessionId, bridgeSessionId: onlyofficeSessionId, bridgeSessionReady: Boolean(onlyofficeSessionId), bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(), bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(), lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0), preview: officeOpenMode === 'preview', pinned: false, }; }; const buildOpenEditorsSnapshot = () => { const generatedAt = Date.now(); const pageEntries = ['primary', 'secondary'].map((paneRole) => { const nodes = resourceTabHostNodes(paneRole); const documentId = documentIdForPane(paneRole); const workspaceId = String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(); const sourceKind = currentWebShellSourceKind(); const rootUri = currentWebShellRootUri(); const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : ''; const objectIdentity = `page:${paneRole}`; const workspaceObjectIdentity = { objectKind: 'page', documentId, blockId: null, assetId: null, }; const active = nodes.pageTab instanceof HTMLElement ? nodes.pageTab.getAttribute('aria-selected') === 'true' : false; const pageSession = documentSessionForPane(paneRole, documentId); const dirtyState = documentSessionDirtyState(pageSession); const pageTitle = nodes.pageTab instanceof HTMLElement ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() : '页面'; return { objectIdentity, workspacePath: buildWorkspacePath({ workspaceId, sourceKind, rootUri, relativePath, documentId, objectIdentity: workspaceObjectIdentity, assetId: '', resourceKind: 'page', }), paneRole, documentId, workspaceId, title: pageTitle || '页面', kind: 'page', editorKind: 'page', badgeKind: 'code', active, dirtyState, dirtyGuard: dirtyState, lastActiveAt: active ? generatedAt : 0, preview: false, pinned: true, }; }).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, generatedAt)); }); const editors = [...pageEntries, ...resources]; const groupForPane = (paneRole) => { const role = normalizePaneRole(paneRole); const groupEditors = editors.filter((entry) => normalizePaneRole(entry.paneRole) === role); const groupResources = resources.filter((entry) => normalizePaneRole(entry.paneRole) === role); const groupActive = groupEditors.find((entry) => entry.active); return { paneRole: role, activeObjectIdentity: groupActive?.objectIdentity || '', editors: groupEditors, resourceEditors: groupResources, }; }; const groups = { primary: groupForPane('primary'), secondary: groupForPane('secondary'), }; const activeObjectIdentity = groups.primary.activeObjectIdentity || groups.secondary.activeObjectIdentity || ''; return { schema: 'mnote.open_editors_snapshot.v1', generatedAt, activeObjectIdentity, editors, resourceEditors: resources, groups, }; }; 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 syncActiveResourceWidthType = (activeEntry, paneRole = 'primary') => { const role = normalizePaneRole(paneRole); const widthType = role === 'primary' ? resourceTabWidthType(activeEntry) : ''; const targetNodes = [document.documentElement, document.body].filter((node) => node instanceof HTMLElement); targetNodes.forEach((node) => { if (widthType) node.setAttribute('data-mnote-active-resource-width-type', widthType); else node.removeAttribute('data-mnote-active-resource-width-type'); }); const shell = document.querySelector(`.document-pane[data-pane-role="${role}"] .document-shell`); if (shell instanceof HTMLElement) { if (widthType) shell.setAttribute('data-mnote-active-resource-width-type', widthType); else shell.removeAttribute('data-mnote-active-resource-width-type'); } window.dispatchEvent(new CustomEvent('mnote:active-resource-tab-changed', { detail: { paneRole: role, active: Boolean(activeEntry), widthType, objectIdentity: String(activeEntry?.objectIdentity || '') } })); }; 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); }); syncActiveResourceWidthType(activeEntry, role); if (activeEntry) markIntendedSlashRoot(activeEntry); if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null; if (role === 'primary') syncActiveResourceFileTreeRow(activeResource); syncActiveResourceUrlState(activeResource, role); syncActivePassiveResourceWatch(role, activeResource); 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 = async (objectIdentity) => { const key = String(objectIdentity || '').trim(); const entry = resourceTabRegistry.get(key); if (!entry) return; await refreshResourceTabBufferState(entry); 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); } } releaseInlinePdfResource(entry); 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.setAttribute('data-resource-path', String(input.path || '').trim()); panel.hidden = true; nodes.strip.append(tab); nodes.panelRoot.append(panel); return { objectIdentity, registryKey, paneRole, title, kind, badgeKind: resourceTabBadgeKind(input, 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(), workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || '').trim(), sourceKind: String(input.sourceKind || currentWebShellSourceKind() || '').trim(), rootUri: String(input.rootUri || currentWebShellRootUri() || '').trim(), workspacePath: buildWorkspacePath({ workspacePath: input.workspacePath, workspaceId: input.workspaceId || currentWebShellWorkspaceId(), sourceKind: input.sourceKind || currentWebShellSourceKind(), rootUri: input.rootUri || currentWebShellRootUri(), relativePath: input.path, documentId: input.documentId, objectIdentity, assetId: input.assetId, resourceKind: kind, }), lastActiveAt: 0, }; }; const releaseResourceTabEntryRuntime = (entry) => { if (!entry) return; releaseInlinePdfResource(entry); 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; } closePassiveResourceWatch(entry); 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 localResourceStatUrl = (rootUri, path) => { const url = new URL('/api/local-folder/files/stat', window.location.origin); url.searchParams.set('rootUri', rootUri || ''); url.searchParams.set('path', path || ''); return url.toString(); }; const withResourceReloadToken = (href) => { if (!href) return href; const url = new URL(href, window.location.origin); url.searchParams.set('mnoteResourceReload', String(Date.now())); return url.toString(); }; const normalizeEvidenceBBox = (value) => { if (!value) return null; if (Array.isArray(value) && value.length >= 4) { const values = value.slice(0, 4).map((item) => Number(item)); return values.every(Number.isFinite) ? { x0: values[0], y0: values[1], x1: values[2], y1: values[3] } : null; } if (typeof value === 'object') { const bbox = { x0: Number(value.x0), y0: Number(value.y0), x1: Number(value.x1), y1: Number(value.y1), }; return Object.values(bbox).every(Number.isFinite) ? bbox : null; } if (typeof value === 'string') { const parts = value.split(',').map((item) => Number(item.trim())); return parts.length >= 4 && parts.slice(0, 4).every(Number.isFinite) ? { x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] } : null; } return null; }; const normalizeEvidenceLocatorInput = (input = {}) => { const locator = input.evidenceLocator && typeof input.evidenceLocator === 'object' ? input.evidenceLocator : input.locator && typeof input.locator === 'object' ? input.locator : null; const params = locator?.openAction?.params && typeof locator.openAction.params === 'object' ? locator.openAction.params : {}; const page = Number(input.page ?? locator?.page ?? params.page); const bbox = normalizeEvidenceBBox(input.bbox ?? locator?.bbox ?? params.bbox); const sourceMapPath = String(input.sourceMapPath || locator?.sourceMapPath || params.sourceMapPath || '').trim(); const blockId = String(input.blockId || locator?.blockId || params.blockId || '').trim(); const lineRange = input.lineRange || locator?.lineRange || params.lineRange || null; const charRange = input.charRange || locator?.charRange || params.charRange || null; if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !lineRange && !charRange) return null; return { schema: 'mnote.evidence_locator.v1', ...(locator || {}), page: Number.isFinite(page) ? page : null, bbox, sourceMapPath, blockId, lineRange, charRange, }; }; const evidenceBBoxParam = (bbox) => { const normalized = normalizeEvidenceBBox(bbox); return normalized ? [normalized.x0, normalized.y0, normalized.x1, normalized.y1].join(',') : ''; }; const applyEvidenceLocatorToFrame = (frame, locator) => { if (!(frame instanceof HTMLIFrameElement) || !locator) return; try { const url = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin); if (Number.isFinite(Number(locator.page))) url.searchParams.set('page', String(Number(locator.page))); const bbox = evidenceBBoxParam(locator.bbox); if (bbox) url.searchParams.set('bbox', bbox); if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath)); if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId)); if (url.pathname === '/office-preview' && frame.contentWindow) { frame.contentWindow.postMessage({ type: 'mnote:office-evidence-locator', page: locator.page, bbox, sourceMapPath: locator.sourceMapPath || '', blockId: locator.blockId || '', }, window.location.origin); return; } frame.src = url.toString(); } catch (_) {} }; const applyEvidenceLocatorToImagePanel = (entry, locator) => { if (!(entry?.panel instanceof HTMLElement) || !locator) return; const bbox = normalizeEvidenceBBox(locator.bbox); let overlay = entry.panel.querySelector('[data-mnote-evidence-bbox-highlight]'); if (!bbox) { if (overlay instanceof HTMLElement) overlay.hidden = true; return; } if (!(overlay instanceof HTMLElement)) { overlay = document.createElement('div'); overlay.className = 'mnote-resource-tab-bbox-highlight'; overlay.setAttribute('data-mnote-evidence-bbox-highlight', 'true'); entry.panel.append(overlay); } overlay.hidden = false; overlay.style.left = `${Math.max(0, bbox.x0)}px`; overlay.style.top = `${Math.max(0, bbox.y0)}px`; overlay.style.width = `${Math.max(1, bbox.x1 - bbox.x0)}px`; overlay.style.height = `${Math.max(1, bbox.y1 - bbox.y0)}px`; }; const applyEvidenceLocatorToTextPanel = (entry, locator) => { if (!(entry?.panel instanceof HTMLElement) || !locator) return; const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); if (!(root instanceof HTMLElement)) return; const selector = locator.blockId ? `[data-block-id="${cssSafe(locator.blockId)}"]` : ''; let target = selector ? root.querySelector(selector) : null; const query = String(locator?.openAction?.params?.query || locator?.query || '').trim(); if (!(target instanceof HTMLElement) && query) { target = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]')) .find((node) => node instanceof HTMLElement && String(node.textContent || '').includes(query)) || null; } const lineStart = Number(locator?.lineRange?.start ?? locator?.line_range?.start ?? 0); if (!(target instanceof HTMLElement) && Number.isFinite(lineStart) && lineStart > 0) { const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]')) .filter((node) => node instanceof HTMLElement); target = blocks[Math.max(0, Math.min(blocks.length - 1, lineStart - 1))] || null; } if (!(target instanceof HTMLElement)) return; root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => { if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight'); }); target.setAttribute('data-mnote-evidence-text-highlight', 'true'); target.scrollIntoView({ block: 'center', inline: 'nearest' }); }; const applyEvidenceLocatorToInlinePdfPanel = (entry, locator) => { if (!(entry?.panel instanceof HTMLElement) || !locator) return; const pageNumber = Number(locator.page || 0); if (!Number.isFinite(pageNumber) || pageNumber <= 0) return; const canvas = entry.panel.querySelector(`canvas.mnote-pdf-page[data-page-number="${pageNumber}"]`); if (!(canvas instanceof HTMLCanvasElement)) return; entry.panel.querySelectorAll('canvas.mnote-pdf-page[data-mnote-evidence-page="true"]').forEach((node) => { if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-page'); }); canvas.setAttribute('data-mnote-evidence-page', 'true'); canvas.scrollIntoView({ block: 'center', inline: 'nearest' }); }; const applyEvidenceLocatorToEntry = (entry, input = {}) => { if (!(entry?.panel instanceof HTMLElement)) return; const locator = normalizeEvidenceLocatorInput(input); if (!locator) return; entry.evidenceLocator = locator; entry.panel.setAttribute('data-mnote-evidence-locator', JSON.stringify(locator)); entry.panel.setAttribute('data-mnote-evidence-open', 'true'); if (Number.isFinite(Number(locator.page))) entry.panel.setAttribute('data-mnote-evidence-page', String(Number(locator.page))); if (locator.blockId) entry.panel.setAttribute('data-mnote-evidence-block-id', String(locator.blockId)); if (locator.sourceMapPath) entry.panel.setAttribute('data-mnote-evidence-source-map-path', String(locator.sourceMapPath)); const bbox = evidenceBBoxParam(locator.bbox); if (bbox) entry.panel.setAttribute('data-mnote-evidence-bbox', bbox); const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame'); if (frame instanceof HTMLIFrameElement) applyEvidenceLocatorToFrame(frame, locator); if (entry.kind === 'image') applyEvidenceLocatorToImagePanel(entry, locator); if (entry.kind === 'pdf') applyEvidenceLocatorToInlinePdfPanel(entry, locator); if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') { window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 80); window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 450); } }; const renderPassiveResourceMissing = (entry, message) => { if (!(entry?.panel instanceof HTMLElement)) return; entry.panel.setAttribute('data-mnote-resource-missing', 'true'); entry.panel.innerHTML = '

资源已不可用

'; const text = entry.panel.querySelector('p'); if (text instanceof HTMLElement) text.textContent = message || '本地资源文件已被删除或移动。'; }; const reloadPassiveResourceTab = (entry) => { if (!(entry?.panel instanceof HTMLElement)) return; entry.panel.removeAttribute('data-mnote-resource-missing'); const img = entry.panel.querySelector('img.mnote-resource-tab-image'); if (img instanceof HTMLImageElement) { img.src = withResourceReloadToken(img.getAttribute('src') || img.src || ''); return; } const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame'); if (frame instanceof HTMLIFrameElement) { frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || ''); return; } if (entry.kind === 'pdf' && entry.inlinePdfSourceHref) { void openPassiveResourceTab(entry, { ...(entry.lastPassiveInput || {}), href: withResourceReloadToken(entry.inlinePdfSourceHref) }); } }; const closePassiveResourceWatch = (entry) => { if (!entry?.resourceWatchEventSource) return; try { entry.resourceWatchEventSource.close(); } catch (_) {} entry.resourceWatchEventSource = null; if (entry.panel instanceof HTMLElement) entry.panel.removeAttribute('data-mnote-resource-watch-ready'); }; const isActiveResourceTabEntry = (entry) => { if (!(entry?.panel instanceof HTMLElement)) return false; if (entry.panel.hidden === false) return true; return entry.tab instanceof HTMLElement && entry.tab.getAttribute('aria-selected') === 'true'; }; const closeInactivePassiveResourceWatches = (activeEntry) => { const role = normalizePaneRole(activeEntry?.paneRole || 'primary'); resourceTabRegistry.forEach((entry) => { if (entry === activeEntry) return; if (normalizePaneRole(entry?.paneRole) !== role) return; closePassiveResourceWatch(entry); }); }; const installPassiveResourceWatch = (entry) => { if (!entry || entry.session || typeof window.EventSource !== 'function') return; const rootUri = String(entry.rootUri || '').trim(); const path = String(entry.path || '').trim(); if (!rootUri || !path) return; if (!isActiveResourceTabEntry(entry)) { closePassiveResourceWatch(entry); return; } closeInactivePassiveResourceWatches(entry); if (entry.resourceWatchEventSource) { try { entry.resourceWatchEventSource.close(); } catch (_) {} entry.resourceWatchEventSource = null; } const url = new URL('/api/local-folder/events', window.location.origin); url.searchParams.set('rootUri', rootUri); url.searchParams.set('resourcePath', path); const eventSource = new EventSource(url.toString()); entry.resourceWatchEventSource = eventSource; eventSource.addEventListener('ready', () => { if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true'); }); eventSource.addEventListener('change', async () => { try { const response = await fetch(localResourceStatUrl(rootUri, path), { cache: 'no-store', headers: { accept: 'application/json' }, }); const payload = await response.json().catch(() => null); const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists); if (!exists) { renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。'); return; } reloadPassiveResourceTab(entry); } catch (error) { renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error)); } }); }; const syncActivePassiveResourceWatch = (paneRole, activeResource) => { const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null; if (!activeEntry) { resourceTabRegistry.forEach((entry) => { if (normalizePaneRole(entry?.paneRole) === normalizePaneRole(paneRole)) closePassiveResourceWatch(entry); }); return; } installPassiveResourceWatch(activeEntry); }; const releaseInlinePdfResource = (entry) => { if (!entry) return; const pdf = entry.inlinePdfDocument; entry.inlinePdfDocument = null; entry.inlinePdfRenderToken = null; if (!pdf) return; try { void pdf.destroy(); } catch (_) {} }; const pdfFileUrlFromPreviewHref = (href) => { const value = String(href || '').trim(); if (!value) return ''; try { const url = new URL(value, window.location.origin); return String(url.searchParams.get('fileUrl') || value).trim(); } catch (_) { return value; } }; const renderInlinePdfPage = async (entry, viewer, pdf, pageNumber, evidenceLocator) => { const page = await pdf.getPage(pageNumber); const baseViewport = page.getViewport({ scale: 1 }); const availableWidth = Math.max(280, viewer.clientWidth - 20); const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width)); const viewport = page.getViewport({ scale }); const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1)); const canvas = document.createElement('canvas'); canvas.className = 'mnote-pdf-page'; canvas.setAttribute('data-page-number', String(pageNumber)); canvas.width = Math.floor(viewport.width * outputScale); canvas.height = Math.floor(viewport.height * outputScale); canvas.style.display = 'block'; canvas.style.maxWidth = '100%'; canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`; canvas.style.margin = '0 auto 14px'; canvas.style.background = '#fff'; canvas.style.border = '1px solid #d8d8d2'; canvas.style.boxShadow = '0 2px 10px rgba(25, 25, 22, .08)'; const context = canvas.getContext('2d', { alpha: false }); if (!context) return; await page.render({ canvasContext: context, viewport, transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null, }).promise; const evidencePage = Number(evidenceLocator?.page || 0); if (evidencePage === pageNumber) { canvas.setAttribute('data-mnote-evidence-page', 'true'); const bbox = normalizeEvidenceBBox(evidenceLocator?.bbox); if (bbox) { const rect = viewport.convertToViewportRectangle([bbox.x0, bbox.y0, bbox.x1, bbox.y1]); const x = Math.min(rect[0], rect[2]); const y = Math.min(rect[1], rect[3]); const width = Math.max(1, Math.abs(rect[2] - rect[0])); const height = Math.max(1, Math.abs(rect[3] - rect[1])); context.save(); context.scale(outputScale, outputScale); context.fillStyle = 'rgba(37, 99, 235, 0.18)'; context.strokeStyle = 'rgba(37, 99, 235, 0.9)'; context.lineWidth = 2; context.fillRect(x, y, width, height); context.strokeRect(x, y, width, height); context.restore(); } } if (entry.inlinePdfDocument === pdf) viewer.append(canvas); if (evidencePage === pageNumber) window.setTimeout(() => canvas.scrollIntoView({ block: 'center', inline: 'nearest' }), 0); }; const openInlinePdfResourceTab = async (entry, input) => { const href = String(input.officeUrl || input.href || '').trim(); const fileUrl = pdfFileUrlFromPreviewHref(href); if (!(entry?.panel instanceof HTMLElement) || !fileUrl) return false; releaseInlinePdfResource(entry); const renderToken = {}; entry.passiveFrameSrc = href; entry.inlinePdfSourceHref = href; entry.inlinePdfRenderToken = renderToken; entry.lastPassiveInput = { ...input }; entry.panel.replaceChildren(); const viewer = document.createElement('div'); viewer.className = 'mnote-pdf-viewer'; viewer.setAttribute('data-mnote-inline-pdf-viewer', 'true'); viewer.style.width = '100%'; viewer.style.maxWidth = '1180px'; viewer.style.margin = '0 auto'; viewer.style.padding = '8px 12px 28px'; entry.panel.append(viewer); const pdfjsLib = await import('/api/pdfjs/pdf.mjs'); pdfjsLib.GlobalWorkerOptions.workerSrc = '/api/pdfjs/pdf.worker.mjs'; const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(window.location.origin); const pdf = await pdfjsLib.getDocument({ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }).promise; entry.inlinePdfDocument = pdf; const total = Number(pdf.numPages || 0); viewer.setAttribute('data-mnote-pdf-status', `0 / ${total}`); const evidenceLocator = normalizeEvidenceLocatorInput(input); for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) { if (entry.inlinePdfDocument !== pdf || entry.inlinePdfRenderToken !== renderToken) return true; await renderInlinePdfPage(entry, viewer, pdf, pageNumber, evidenceLocator); viewer.setAttribute('data-mnote-pdf-status', `${pageNumber} / ${total}`); } installPassiveResourceWatch(entry); return true; }; const isLocalOcrSourceEntry = (entry) => { if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false; if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false; return entry.kind === 'image' || entry.kind === 'pdf'; }; const localOcrProvider = () => { const override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase(); return override === 'mock' ? 'mock' : 'mineru'; }; const localOcrAutoEnabled = async () => { const cached = window.__MNOTE_LOCAL_OCR_PREFERENCES; if (cached && typeof cached === 'object' && cached['localOcr.autoEnabled'] === true) return true; const documentId = currentWebShellDocumentId(); const workspaceId = currentWebShellWorkspaceId(); const sourceKind = currentWebShellSourceKind(); const rootUri = currentWebShellRootUri(); if (!documentId && !workspaceId) return false; try { const params = new URLSearchParams(); if (documentId) params.set('documentId', documentId); if (workspaceId) params.set('workspaceId', workspaceId); if (sourceKind) params.set('sourceKind', sourceKind); if (rootUri) params.set('rootUri', rootUri); const response = await fetch('/api/ui/preferences/effective?' + params.toString(), { cache: 'no-store', headers: { accept: 'application/json' }, }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) return false; const preferences = payload.result?.localOcrPreferences && typeof payload.result.localOcrPreferences === 'object' ? payload.result.localOcrPreferences : {}; window.__MNOTE_LOCAL_OCR_PREFERENCES = { 'localOcr.autoEnabled': false, ...preferences }; return window.__MNOTE_LOCAL_OCR_PREFERENCES['localOcr.autoEnabled'] === true; } catch (_) { return false; } }; const setLocalOcrStatus = (entry, status, message, job) => { if (!(entry?.panel instanceof HTMLElement)) return; const normalizedStatus = String(status || '').trim() || 'unknown'; entry.localOcrJob = job && typeof job === 'object' ? job : entry.localOcrJob || null; entry.panel.setAttribute('data-mnote-local-ocr-status', normalizedStatus); if (entry.localOcrJob?.ocrRootRelativePath) { entry.panel.setAttribute('data-mnote-local-ocr-path', String(entry.localOcrJob.ocrRootRelativePath)); } const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]'); if (statusNode instanceof HTMLElement) { statusNode.textContent = message || ( normalizedStatus === 'done' ? 'OCR 已完成' : normalizedStatus === 'running' ? 'OCR 处理中' : normalizedStatus === 'failed' ? 'OCR 失败' : normalizedStatus === 'stale' ? 'OCR 需更新' : 'OCR 未生成' ); } const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]'); if (openButton instanceof HTMLButtonElement) openButton.disabled = !entry.localOcrJob?.ocrRootRelativePath; const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]'); if (insertButton instanceof HTMLButtonElement) insertButton.disabled = !entry.localOcrJob?.ocrRootRelativePath; window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', { detail: { status: normalizedStatus, job: entry.localOcrJob || null, rootUri: entry.rootUri || '', sourceRootRelativePath: entry.path || '', }, })); }; const readLocalOcrStatus = async (entry) => { if (!isLocalOcrSourceEntry(entry)) return null; const url = new URL('/api/local-folder/ocr/status', window.location.origin); url.searchParams.set('rootUri', entry.rootUri); url.searchParams.set('sourceRootRelativePath', entry.path); 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) return null; return payload.job || null; }; const localOcrTaskState = { rootUri: '', jobsBySource: new Map(), drawerOpen: false, taskFilter: 'active', eventSource: null, fileTreeRefreshKeys: new Set(), }; const localOcrJobKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim(); const localOcrParentRelativePath = (relativePath) => { const normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); if (!normalized || normalized.indexOf('/') < 0) return ''; return normalized.split('/').slice(0, -1).join('/'); }; const dispatchLocalOcrFileTreeRefresh = (job, rootUri) => { const status = String(job?.status || '').trim(); if (!['done', 'stale'].includes(status)) return; const ocrPath = String(job?.ocrRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); const normalizedRootUri = String(rootUri || localOcrTaskState.rootUri || '').trim(); if (!ocrPath || !normalizedRootUri) return; const refreshKey = `${ocrPath}:${String(job?.updatedAtMs || job?.finishedAtMs || status)}`; if (localOcrTaskState.fileTreeRefreshKeys.has(refreshKey)) return; localOcrTaskState.fileTreeRefreshKeys.add(refreshKey); const ocrParent = localOcrParentRelativePath(ocrPath); const ocrParentParent = localOcrParentRelativePath(ocrParent); const affectedParents = [ocrParent, ocrParentParent] .filter((path, index, list) => index === list.indexOf(path)) .map((relativePath) => ({ relativePath, reason: 'local-ocr-sidecar-written' })); const changedPaths = [ { relativePath: ocrPath, changeType: 'created' }, ocrParent ? { relativePath: ocrParent, changeType: 'created' } : null, ].filter(Boolean); document.documentElement.setAttribute('data-mnote-local-ocr-filetree-refresh', ocrPath); window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { detail: { payload: { schema: 'mnote.local_folder.watch_batch.v1', source: 'local_ocr.job.updated', rootUri: normalizedRootUri, revision: String(job?.updatedAtMs || Date.now()), changedPaths, affectedParents, }, }, })); }; const updateLocalOcrTaskState = (job) => { const key = localOcrJobKey(job); if (!key) return; if (String(job?.status || '').trim() === 'deleted') { localOcrTaskState.jobsBySource.delete(key); renderLocalOcrTaskDock(); return; } localOcrTaskState.jobsBySource.set(key, job); renderLocalOcrTaskDock(); }; const bindLocalOcrTopbarAction = () => { const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); if (!(toggle instanceof HTMLButtonElement)) return null; if (toggle.getAttribute('data-mnote-local-ocr-bound') === 'true') return toggle; toggle.setAttribute('data-mnote-local-ocr-bound', 'true'); toggle.addEventListener('click', (event) => { if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') { event.preventDefault(); window.dispatchEvent(new CustomEvent('mnote:open-local-ocr-settings')); return; } void runManualLocalOcrForActiveTarget(toggle).catch((error) => { console.warn('mnote local OCR 手动入口失败', error); }); }); return toggle; }; const localOcrJobSnapshotFromEntry = (entry, status, provider, message, timestamp = Date.now()) => { const sourceRootRelativePath = String(entry?.path || '').trim(); return { jobId: `local-ocr-${status}:${sourceRootRelativePath || 'unknown'}:${timestamp}`, ownerDocumentId: String(entry?.ownerDocumentId || entry?.documentId || currentWebShellDocumentId() || '').trim(), sourceRootRelativePath, rootUri: String(entry?.rootUri || localOcrTaskState.rootUri || '').trim(), ocrRootRelativePath: '', provider: String(provider || localOcrProvider()), status, stageLabel: message || statusTextForLocalOcrJob({ status }), stale: false, updatedAtMs: timestamp, finishedAtMs: status === 'failed' ? timestamp : null, error: status === 'failed' ? String(message || '') : '', }; }; const statusTextForLocalOcrJob = (job) => { const status = String(job?.status || '').trim(); if (job?.stageLabel) return String(job.stageLabel); if (job?.taskKind === 'local_index') { return status === 'done' ? '索引已完成' : status === 'failed' ? '索引失败' : status === 'running' ? '索引中' : status || '未知'; } return status === 'done' ? '已识别' : status === 'failed' ? '识别失败' : status === 'stale' ? '来源已变化' : status === 'running' ? '处理中' : status || '未知'; }; const localOcrDisplayStatus = (job, fallback = 'done') => { const status = String(job?.status || fallback).trim() || fallback; return job?.stale === true && status === 'done' ? 'stale' : status; }; const localOcrTaskCategory = (job) => { const status = String(job?.status || '').trim(); if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention'; if (['done', 'succeeded', 'success'].includes(status)) return 'completed'; return 'active'; }; const localOcrTaskProgress = (job) => { const current = Number(job?.progressCurrent ?? job?.currentProgress); const total = Number(job?.progressTotal ?? job?.maxProgress); if (Number.isFinite(current) && Number.isFinite(total) && total > 0) { return Math.max(0, Math.min(100, Math.round((current / total) * 100))); } return null; }; const localOcrTaskFilterLabel = (filter) => { return filter === 'active' ? '进行中' : filter === 'completed' ? '已完成' : filter === 'attention' ? '需处理' : '全部'; }; const ensureLocalOcrTaskDock = () => { let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]'); if (!(dock instanceof HTMLElement)) { dock = document.createElement('section'); dock.className = 'mnote-local-ocr-task-dock'; dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock'); dock.innerHTML = ''; document.body.appendChild(dock); } let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); if (!(toggle instanceof HTMLButtonElement)) { const actions = document.querySelector('.wolai-topbar-actions'); toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle'; toggle.setAttribute('title', 'OCR 设置'); toggle.setAttribute('aria-label', 'OCR 设置'); toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle'); toggle.setAttribute('data-mnote-action', 'open-ocr-settings'); toggle.innerHTML = ''; if (actions instanceof HTMLElement) actions.appendChild(toggle); else document.body.appendChild(toggle); } bindLocalOcrTopbarAction(); if (dock.getAttribute('data-mnote-local-ocr-bound') === 'true') return dock; dock.setAttribute('data-mnote-local-ocr-bound', 'true'); dock.addEventListener('click', (event) => { const closeButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-close]') : null; if (closeButton instanceof HTMLElement) { localOcrTaskState.drawerOpen = false; renderLocalOcrTaskDock(); return; } const clearButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear]') : null; if (clearButton instanceof HTMLElement) { const sourcePath = clearButton.getAttribute('data-mnote-local-ocr-task-clear') || ''; if (sourcePath) { localOcrTaskState.jobsBySource.delete(sourcePath); renderLocalOcrTaskDock(); } return; } const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-tab]') : null; if (tabButton instanceof HTMLElement) { localOcrTaskState.taskFilter = tabButton.getAttribute('data-mnote-local-ocr-task-tab') || 'active'; renderLocalOcrTaskDock(); return; } const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear-completed]') : null; if (clearCompleted instanceof HTMLElement) { Array.from(localOcrTaskState.jobsBySource.entries()).forEach(([key, job]) => { if (localOcrTaskCategory(job) === 'completed') localOcrTaskState.jobsBySource.delete(key); }); renderLocalOcrTaskDock(); return; } const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null; if (deleteButton instanceof HTMLElement) { const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || ''; if (sourcePath) { void deleteLocalOcrJob(sourcePath).catch((error) => { const message = error instanceof Error ? error.message : String(error); document.documentElement.setAttribute('data-mnote-local-ocr-delete-error', message); console.warn('mnote local OCR 删除失败', error); }); } return; } const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null; if (openButton instanceof HTMLElement) { const sourcePath = openButton.getAttribute('data-mnote-local-ocr-task-open') || ''; const job = localOcrTaskState.jobsBySource.get(sourcePath) || null; if (job) { void openResourceInActiveTab({ kind: 'markdown', title: String(job.ocrRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR', path: job.ocrRootRelativePath, objectIdentity: `local-ocr:${job.ocrRootRelativePath}`, assetId: `local-ocr:${job.ocrRootRelativePath}`, documentId: job.ownerDocumentId || currentWebShellDocumentId() || '', ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '', workspaceId: currentWebShellWorkspaceId() || '', sourceKind: 'local_folder', rootUri: localOcrTaskState.rootUri || '', resourceKind: 'markdown', }).catch((error) => console.warn('mnote local OCR 任务打开失败', error)); } } const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null; if (retryButton instanceof HTMLElement) { const sourcePath = retryButton.getAttribute('data-mnote-local-ocr-task-retry') || ''; const job = localOcrTaskState.jobsBySource.get(sourcePath) || null; if (job) { const entry = { sourceKind: 'local_folder', rootUri: localOcrTaskState.rootUri, path: sourcePath, kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', title: sourcePath.split('/').filter(Boolean).pop() || sourcePath, documentId: job.ownerDocumentId || currentWebShellDocumentId() || '', ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '', workspaceId: currentWebShellWorkspaceId() || '', localOcrJob: job, }; void createLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 任务重试失败', error)); } } }); return dock; }; const renderLocalOcrTaskDock = () => { const dock = ensureLocalOcrTaskDock(); const jobs = Array.from(localOcrTaskState.jobsBySource.values()) .sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0)); const activeJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'active'); const attentionJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'attention'); const completedJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'completed'); const runningCount = activeJobs.length; const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-local-ocr-task-toggle"], [data-testid="mnote-floating-task-toggle"]')) .filter((node) => node instanceof HTMLButtonElement); taskToggles.forEach((toggle) => { const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings'; const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置'); const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务'); toggle.setAttribute('title', opensSettings ? label : taskLabel); toggle.setAttribute('aria-label', opensSettings ? label : taskLabel); toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false'); toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0); const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]'); if (badge instanceof HTMLElement) { badge.textContent = String(runningCount > 0 ? runningCount : jobs.length); badge.hidden = jobs.length === 0; } else { toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`; } }); const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]'); if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen; const summary = dock.querySelector('[data-mnote-local-ocr-task-summary]'); if (summary instanceof HTMLElement) { summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`; } const tabs = dock.querySelector('[data-mnote-local-ocr-task-tabs]'); if (tabs instanceof HTMLElement) { const tabItems = [ ['active', '进行中', activeJobs.length], ['attention', '需处理', attentionJobs.length], ['completed', '已完成', completedJobs.length], ['all', '全部', jobs.length], ]; tabs.replaceChildren(); tabItems.forEach(([key, label, count]) => { const button = document.createElement('button'); button.type = 'button'; button.setAttribute('data-mnote-local-ocr-task-tab', key); button.setAttribute('aria-selected', localOcrTaskState.taskFilter === key ? 'true' : 'false'); button.textContent = `${label} ${count}`; tabs.appendChild(button); }); } const filterLabel = dock.querySelector('[data-mnote-local-ocr-task-filter-label]'); if (filterLabel instanceof HTMLElement) filterLabel.textContent = localOcrTaskFilterLabel(localOcrTaskState.taskFilter); const clearCompleted = dock.querySelector('[data-mnote-local-ocr-task-clear-completed]'); if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0; const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]'); if (!(list instanceof HTMLElement)) return; list.replaceChildren(); const visibleJobs = jobs.filter((job) => { return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter; }); if (!visibleJobs.length) { const empty = document.createElement('div'); empty.className = 'mnote-local-ocr-task-empty'; empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务'; list.appendChild(empty); return; } visibleJobs.forEach((job) => { const row = document.createElement('div'); row.className = 'mnote-local-ocr-task-row'; row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || '')); row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || '')); row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job)); const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR'; const category = localOcrTaskCategory(job); const progress = localOcrTaskProgress(job); row.innerHTML = '
'; const titleNode = row.querySelector('strong'); if (titleNode instanceof HTMLElement) { titleNode.textContent = title; titleNode.setAttribute('title', String(job.sourceRootRelativePath || title)); } const categoryNode = row.querySelector('em'); if (categoryNode instanceof HTMLElement) { categoryNode.textContent = category === 'active' ? '进行中' : category === 'attention' ? '需处理' : '已完成'; } const statusNode = row.querySelector('span'); if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForLocalOcrJob(job); const progressBar = row.querySelector('.mnote-local-ocr-task-progress'); const progressValue = row.querySelector('.mnote-local-ocr-task-progress i'); if (progressBar instanceof HTMLElement && progressValue instanceof HTMLElement) { progressBar.hidden = category !== 'active' && progress === null; progressBar.setAttribute('aria-valuemin', '0'); progressBar.setAttribute('aria-valuemax', '100'); if (progress === null) { progressBar.setAttribute('data-progress-mode', 'indeterminate'); progressBar.removeAttribute('aria-valuenow'); progressValue.style.width = ''; } else { progressBar.setAttribute('data-progress-mode', 'determinate'); progressBar.setAttribute('aria-valuenow', String(progress)); progressValue.style.width = `${progress}%`; } } const open = row.querySelector('[data-mnote-local-ocr-task-open]'); if (open instanceof HTMLButtonElement) { open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || '')); open.disabled = !job.ocrRootRelativePath; open.hidden = job.taskKind === 'local_index'; } const retry = row.querySelector('[data-mnote-local-ocr-task-retry]'); if (retry instanceof HTMLButtonElement) { retry.setAttribute('data-mnote-local-ocr-task-retry', String(job.sourceRootRelativePath || '')); retry.hidden = job.taskKind === 'local_index' || !['failed', 'stale'].includes(String(job.status || '')); } const clear = row.querySelector('[data-mnote-local-ocr-task-clear]'); if (clear instanceof HTMLButtonElement) { clear.setAttribute('data-mnote-local-ocr-task-clear', String(job.sourceRootRelativePath || '')); } const deleteOcr = row.querySelector('[data-mnote-local-ocr-task-delete]'); if (deleteOcr instanceof HTMLButtonElement) { deleteOcr.setAttribute('data-mnote-local-ocr-task-delete', String(job.sourceRootRelativePath || '')); deleteOcr.hidden = job.taskKind === 'local_index' || !job.ocrRootRelativePath; } list.appendChild(row); }); }; const loadLocalOcrJobs = async (rootUri) => { const normalizedRoot = String(rootUri || '').trim(); if (!normalizedRoot) return; localOcrTaskState.rootUri = normalizedRoot; const url = new URL('/api/local-folder/ocr/jobs', window.location.origin); url.searchParams.set('rootUri', normalizedRoot); 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) return; (Array.isArray(payload.jobs) ? payload.jobs : []).forEach((job) => { if (job && typeof job === 'object') job.rootUri = normalizedRoot; updateLocalOcrTaskState(job); }); renderLocalOcrTaskDock(); }; const ensureLocalOcrTaskEvents = (rootUri) => { const normalizedRoot = String(rootUri || '').trim(); if (!normalizedRoot || typeof window.EventSource !== 'function') return; if (localOcrTaskState.eventSource && localOcrTaskState.rootUri === normalizedRoot) return; if (localOcrTaskState.eventSource) { try { localOcrTaskState.eventSource.close(); } catch (_) {} localOcrTaskState.eventSource = null; } localOcrTaskState.rootUri = normalizedRoot; const url = new URL('/api/local-folder/events', window.location.origin); url.searchParams.set('rootUri', normalizedRoot); const eventSource = new EventSource(url.toString()); localOcrTaskState.eventSource = eventSource; eventSource.addEventListener('local_ocr.job.updated', (event) => { let payload = null; try { payload = JSON.parse(event.data || '{}'); } catch (_) {} if (payload?.job) { if (payload.job && typeof payload.job === 'object') payload.job.rootUri = payload.rootUri || normalizedRoot; updateLocalOcrTaskState(payload.job); dispatchLocalOcrFileTreeRefresh(payload.job, payload.rootUri || normalizedRoot); } }); }; const openLocalOcrSidecar = async (entry, job) => { const target = job || entry?.localOcrJob || null; const ocrPath = String(target?.ocrRootRelativePath || '').trim(); if (!ocrPath || typeof openResourceInActiveTab !== 'function') return false; const title = ocrPath.split('/').filter(Boolean).pop() || 'OCR'; return await openResourceInActiveTab({ kind: 'markdown', title, path: ocrPath, objectIdentity: `local-ocr:${ocrPath}`, assetId: `local-ocr:${ocrPath}`, documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(), ownerDocumentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(), workspaceId: String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(), sourceKind: 'local_folder', rootUri: String(entry.rootUri || '').trim(), resourceKind: 'markdown', paneRole: normalizePaneRole(entry.paneRole), }); }; const insertLocalOcrLink = async (entry, job) => { const target = job || entry?.localOcrJob || null; const ocrPath = String(target?.ocrRootRelativePath || '').trim(); if (!ocrPath) return false; const response = await fetch('/api/local-folder/ocr/insert', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ rootUri: String(entry.rootUri || '').trim(), documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(), ocrRootRelativePath: ocrPath, mode: 'link', }), }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) { throw new Error(payload?.error?.message || `local_ocr_insert_failed_${response.status}`); } setLocalOcrStatus(entry, 'done', 'OCR 链接已插入正文', target); return true; }; const createLocalOcrJob = async (entry) => { if (!isLocalOcrSourceEntry(entry)) return null; const provider = localOcrProvider(); const entryRootUri = String(entry.rootUri || '').trim(); if (entryRootUri) localOcrTaskState.rootUri = entryRootUri; const startedAt = Date.now(); const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', provider, '处理中', startedAt); setLocalOcrStatus(entry, 'running', 'OCR 处理中', pendingJob); updateLocalOcrTaskState(pendingJob); const body = { rootUri: String(entry.rootUri || '').trim(), documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(), sourceRootRelativePath: String(entry.path || '').trim(), provider, }; if (provider === 'mock') { body.mockMarkdown = `# OCR Result\n\n${entry.title || entry.path} OCR UI smoke text`; } const response = await fetch('/api/local-folder/ocr/jobs', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) { const message = payload?.error?.message || payload?.message || payload?.error?.code || `local_ocr_job_failed_${response.status}`; const failedJob = { ...pendingJob, status: 'failed', stageLabel: message, updatedAtMs: Date.now(), finishedAtMs: Date.now(), error: message, }; setLocalOcrStatus(entry, 'failed', message, failedJob); updateLocalOcrTaskState(failedJob); throw new Error(message); } const job = payload.job && typeof payload.job === 'object' ? { ...payload.job, rootUri: entryRootUri } : null; const displayStatus = localOcrDisplayStatus(job, 'done'); setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job); if (job) updateLocalOcrTaskState(job); return job; }; const deleteLocalOcrJob = async (sourceRootRelativePath) => { const sourcePath = String(sourceRootRelativePath || '').trim(); if (!sourcePath) return false; const job = localOcrTaskState.jobsBySource.get(sourcePath) || null; const rootUri = String(job?.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim(); const response = await fetch('/api/local-folder/ocr/delete', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ rootUri, sourceRootRelativePath: sourcePath, }), }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) { throw new Error(payload?.error?.message || `local_ocr_delete_failed_${response.status}`); } if (payload.deleted !== true) { throw new Error('local_ocr_delete_noop'); } localOcrTaskState.jobsBySource.delete(sourcePath); renderLocalOcrTaskDock(); const ocrPath = String(job?.ocrRootRelativePath || '').trim(); if (ocrPath) { dispatchLocalOcrFileTreeRefresh({ ...job, status: 'stale', updatedAtMs: Date.now(), ocrRootRelativePath: ocrPath }, localOcrTaskState.rootUri); } return true; }; const maybeAutoCreateLocalOcrJob = async (entry) => { if (!isLocalOcrSourceEntry(entry)) return; if (!await localOcrAutoEnabled()) return; const existing = await readLocalOcrStatus(entry); const status = String(existing?.status || '').trim(); if (existing && !existing.stale && ['done', 'running'].includes(status)) { updateLocalOcrTaskState(existing); return; } await createLocalOcrJob(entry); }; const activeResourceTabEntry = (paneRole = 'primary') => { const role = normalizePaneRole(paneRole); for (const entry of resourceTabRegistry.values()) { if (normalizePaneRole(entry?.paneRole) !== role) continue; if (entry?.tab instanceof HTMLElement && entry.tab.getAttribute('aria-selected') === 'true') return entry; } return null; }; const localOcrCandidatesFromActiveMarkdown = (paneRole = 'primary') => { const role = normalizePaneRole(paneRole); const sourceKind = currentWebShellSourceKind(); const rootUri = currentWebShellRootUri(); if (sourceKind !== 'local_folder' || !rootUri) return []; const documentId = documentIdForPane(role) || currentWebShellDocumentId(); if (!documentId) return []; const workspaceId = currentWebShellWorkspaceId(); const seen = new Set(); const candidates = []; document.querySelectorAll(`.document-pane[data-pane-role="${role}"] .ProseMirror img`).forEach((image) => { if (!(image instanceof HTMLImageElement)) return; const sourcePath = localFileOpenPathFromTiptapHref(image.getAttribute('src') || image.src || ''); if (!sourcePath || seen.has(sourcePath)) return; seen.add(sourcePath); const title = sourcePath.split('/').filter(Boolean).pop() || sourcePath; candidates.push({ sourceKind: 'local_folder', rootUri, path: sourcePath, kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', title, fileName: title, documentId, ownerDocumentId: documentId, workspaceId, objectIdentity: `local-file:${sourcePath}`, assetId: `local-file:${sourcePath}`, }); }); return candidates; }; const localOcrCandidatesForActiveTarget = (paneRole = 'primary') => { const activeResource = activeResourceTabEntry(paneRole); if (isLocalOcrSourceEntry(activeResource)) return [activeResource]; return localOcrCandidatesFromActiveMarkdown(paneRole); }; const runManualLocalOcrForActiveTarget = async (toggle) => { ensureLocalOcrTaskDock(); const candidates = localOcrCandidatesForActiveTarget('primary'); if (!candidates.length) { if (localOcrTaskState.jobsBySource.size > 0) { localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen; renderLocalOcrTaskDock(); } return []; } if (toggle instanceof HTMLButtonElement) toggle.disabled = true; const jobs = []; let createdCount = 0; try { for (const entry of candidates) { try { ensureLocalOcrTaskEvents(entry.rootUri); const existing = await readLocalOcrStatus(entry); const existingStatus = String(existing?.status || '').trim(); if (existing && !existing.stale && ['done', 'running'].includes(existingStatus)) { updateLocalOcrTaskState(existing); jobs.push(existing); continue; } const job = await createLocalOcrJob(entry); if (job) { createdCount += 1; jobs.push(job); } } catch (error) { console.warn('mnote local OCR 手动任务失败', entry?.path, error); if (localOcrTaskState.jobsBySource.has(String(entry?.path || '').trim())) { createdCount += 1; } } } if (createdCount === 0 && localOcrTaskState.jobsBySource.size > 0) { localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen; } } finally { if (toggle instanceof HTMLButtonElement) toggle.disabled = false; renderLocalOcrTaskDock(); } return jobs; }; window.addEventListener('mnote:local-ocr-settings-action', (event) => { const action = String(event?.detail?.action || '').trim(); if (action === 'run-active') { const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); void runManualLocalOcrForActiveTarget(toggle instanceof HTMLButtonElement ? toggle : null).catch((error) => { console.warn('mnote local OCR 设置入口识别失败', error); }); return; } if (action === 'tasks') { ensureLocalOcrTaskDock(); localOcrTaskState.drawerOpen = true; renderLocalOcrTaskDock(); } }); window.addEventListener('mnote:local-background-task-updated', (event) => { const task = event?.detail?.task && typeof event.detail.task === 'object' ? event.detail.task : null; if (!task) return; updateLocalOcrTaskState({ taskKind: String(task.taskKind || 'local_index'), jobId: String(task.jobId || task.taskId || `local-index-${Date.now()}`), sourceRootRelativePath: String(task.sourceRootRelativePath || task.taskId || 'local-index'), rootUri: String(task.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim(), ocrRootRelativePath: '', provider: String(task.provider || 'mnote-web'), status: String(task.status || 'running'), stageLabel: String(task.stageLabel || ''), stale: false, updatedAtMs: Number(task.updatedAtMs || Date.now()), finishedAtMs: task.finishedAtMs == null ? null : Number(task.finishedAtMs), error: task.error || null, }); }); const renderLocalOcrToolbar = (entry) => { if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return; const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]'); if (!(toolbar instanceof HTMLElement)) return; const runButton = toolbar.querySelector('[data-mnote-local-ocr-action="run"]'); const openButton = toolbar.querySelector('[data-mnote-local-ocr-action="open"]'); const insertButton = toolbar.querySelector('[data-mnote-local-ocr-action="insert"]'); if (runButton instanceof HTMLButtonElement) { runButton.addEventListener('click', async () => { runButton.disabled = true; try { await createLocalOcrJob(entry); } catch (error) { console.warn('mnote local OCR 生成失败', error); } finally { runButton.disabled = false; } }); } if (openButton instanceof HTMLButtonElement) { openButton.addEventListener('click', () => { void openLocalOcrSidecar(entry, entry.localOcrJob).catch((error) => { console.warn('mnote local OCR 打开失败', error); }); }); } if (insertButton instanceof HTMLButtonElement) { insertButton.addEventListener('click', () => { void insertLocalOcrLink(entry, entry.localOcrJob).catch((error) => { console.warn('mnote local OCR 插入失败', error); setLocalOcrStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.localOcrJob || null); }); }); } setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null); ensureLocalOcrTaskDock(); ensureLocalOcrTaskEvents(entry.rootUri); void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); void readLocalOcrStatus(entry).then((job) => { if (!job) return; const displayStatus = localOcrDisplayStatus(job, 'done'); setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job); updateLocalOcrTaskState(job); }).catch(() => undefined); }; const createResourceSession = (entry, input, readResult) => { const resourcePath = String(input.path || ''); const resourceDocumentId = localMarkdownDocumentIdFromRelativePath(resourcePath) || entry.objectIdentity; 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: resourceDocumentId, 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); if (typeof ensureLocalFolderEventChannel === 'function') { ensureLocalFolderEventChannel(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 openReadonlyCodeResourceTab = async (entry, input) => { entry.panel.innerHTML = ''; const shell = document.createElement('div'); shell.className = 'mnote-resource-tab-code-shell'; shell.setAttribute('data-mnote-resource-readonly-preview', 'true'); const header = document.createElement('div'); header.className = 'mnote-resource-tab-code-header'; const title = document.createElement('div'); title.className = 'mnote-resource-tab-code-title'; title.textContent = entry.title; const meta = document.createElement('div'); meta.className = 'mnote-resource-tab-code-meta'; meta.textContent = '只读预览'; header.append(title, meta); const pre = document.createElement('pre'); pre.className = 'mnote-resource-tab-code-preview'; const code = document.createElement('code'); pre.append(code); shell.append(header, pre); entry.panel.append(shell); 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 rawText = typeof readResult.text === 'string' ? readResult.text : ''; const truncated = rawText.length > readonlyCodePreviewMaxChars; code.textContent = truncated ? `${rawText.slice(0, readonlyCodePreviewMaxChars)}\n\n/* 预览已截断,文件过大,请用外部编辑器查看完整内容。 */` : rawText; const fileSize = new Blob([rawText]).size; meta.textContent = truncated ? `只读预览 · 已截断 · ${fileSize.toLocaleString()} bytes` : `只读预览 · ${fileSize.toLocaleString()} bytes`; entry.session = null; entry.view = null; }; const ensureOnlyofficeBridgeReadyListener = () => { if (onlyofficeBridgeReadyListenerBound) return; onlyofficeBridgeReadyListenerBound = true; window.addEventListener('message', (event) => { if (event.origin !== window.location.origin) return; const detail = event.data && typeof event.data === 'object' ? event.data : null; if (!detail || detail.type !== 'mnote:onlyoffice-bridge-ready') return; syncOpenEditorsSnapshot(); }); }; const openPassiveResourceTab = async (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; } applyEvidenceLocatorToEntry(entry, input); ensureLocalOcrTaskDock(); ensureLocalOcrTaskEvents(entry.rootUri); void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error)); installPassiveResourceWatch(entry); return; } if (entry.kind === 'pdf') { await openInlinePdfResourceTab(entry, input); applyEvidenceLocatorToEntry(entry, input); if (isLocalOcrSourceEntry(entry)) { ensureLocalOcrTaskDock(); ensureLocalOcrTaskEvents(entry.rootUri); void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error)); } installPassiveResourceWatch(entry); return; } entry.panel.innerHTML = ''; const frame = entry.panel.querySelector('iframe'); if (frame instanceof HTMLIFrameElement) { frame.title = entry.title; if (entry.kind === 'office') { ensureOnlyofficeBridgeReadyListener(); frame.addEventListener('load', () => { syncOpenEditorsSnapshot(); }, { once: true }); } frame.src = href; entry.passiveFrameSrc = href; } applyEvidenceLocatorToEntry(entry, input); if (isLocalOcrSourceEntry(entry)) { ensureLocalOcrTaskDock(); ensureLocalOcrTaskEvents(entry.rootUri); void loadLocalOcrJobs(entry.rootUri).catch(() => undefined); void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error)); } installPassiveResourceWatch(entry); }; const refreshExistingPdfResourceTab = async (entry, input) => { if (!entry || entry.kind !== 'pdf') return false; const nextHref = String(input.officeUrl || input.href || '').trim(); if (!nextHref) return false; if (nextHref !== String(entry.inlinePdfSourceHref || entry.passiveFrameSrc || '').trim()) { await openPassiveResourceTab(entry, input); } return true; }; const officePreviewBaseHref = (href) => { const raw = String(href || '').trim(); if (!raw) return ''; try { const url = new URL(raw, window.location.origin); ['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'mnoteResourceReload'].forEach((key) => { url.searchParams.delete(key); }); return url.pathname + '?' + url.searchParams.toString(); } catch (_) { return raw; } }; 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 (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void 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); const requestedKind = normalizeResourceTabKind(input); 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) { activateMainEditorTab(registryKey, paneRole); await refreshExistingPdfResourceTab(existing, input); refreshExistingOfficeResourceTab(existing, input); applyEvidenceLocatorToEntry(existing, input); 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') { await openTiptapResourceTab(entry, input); } else if (entry.kind === 'code') { await openReadonlyCodeResourceTab(entry, input); } else { await openPassiveResourceTab(entry, input); } applyEvidenceLocatorToEntry(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(); }; try { bindLocalOcrTopbarAction(); } catch (_) {} return { activateMainEditorTab, bindMainEditorPageTab, buildOpenEditorsSnapshot, closeResourceTabsForPane, normalizePaneRole, openResourceInActiveTab, resolveResourceOpen, syncResourceSessionTabGuards, }; };