import { createMindmapHostRuntime } from './document-mindmap-host-runtime.js'; import { installGlobalSlashMenuPositioning, markIntendedSlashRoot, observeSlashMenuPosition, scheduleSlashMenuPosition, } from './document-slash-position-runtime.js'; import { buildPaneRuntime, clearSecondaryParams, createSecondaryPaneWidthController, currentUrl, installSecondaryPaneCloseButtons, installSecondaryPaneOpenListeners, pushUrlState, replaceUrlState, secondaryQueryParamNames, } from './document-pane-runtime.js'; import { conflictDetectionKeyBelongsToSession, conflictDetectionKeyFromBody, editorDocumentFromTiptapDocument, flattenText, hydrateMindmapAttrsFromDom, legacyBlocksFromEditorDocument, legacyInlineContentToTiptap, pageBodyTiptapDocument, revisionFromConflictKey, toTiptapDocument, } from './document-tiptap-conversion-runtime.js'; (() => { const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__'; const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]'; const EVENT_PREFIX = 'mnote:leptos-tiptap-spike'; const CHANGE_EVENT = `${EVENT_PREFIX}:change`; const SAVE_EVENT = `${EVENT_PREFIX}:save-request`; const READY_EVENT = `${EVENT_PREFIX}:ready`; const STATE_EVENT = `${EVENT_PREFIX}:state`; const ERROR_EVENT = `${EVENT_PREFIX}:error`; const COMMAND_EVENT = `${EVENT_PREFIX}:command`; const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1'; const parseJsonScript = (id) => { const node = document.getElementById(id); if (!node) return null; try { return JSON.parse(node.textContent || 'null'); } catch (error) { console.warn(`mnote rust-web editor bootstrap JSON 解析失败: ${id}`, error); return null; } }; const panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID); if (!panesBootstrap || !Array.isArray(panesBootstrap.panes)) return; if (panesBootstrap.secondaryInvalid === true) clearSecondaryParams(); installSecondaryPaneOpenListeners(); installSecondaryPaneCloseButtons(); const { workspace, applyStoredSecondaryWidth } = createSecondaryPaneWidthController(); applyStoredSecondaryWidth(); const paneRuntimes = panesBootstrap.panes .map((paneDescriptor) => buildPaneRuntime(paneDescriptor, ROOT_SELECTOR)) .filter(Boolean); const loadRuntime = async () => { if (window.__mnoteLeptosTiptapRuntimePromise) { return window.__mnoteLeptosTiptapRuntimePromise; } window.__mnoteLeptosTiptapRuntimePromise = (async () => { const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' }); if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`); const manifest = await manifestResponse.json(); if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath'); const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`; const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined; const runtime = await import(entryUrl); if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') { throw new Error('island runtime 导出不完整'); } await runtime.default({ module_or_path: wasmUrl }); if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') { window.__MNOTE_MINDMAP_RUST_SHELL__ = { mount: runtime.mount_mindmap_shell, unmount: runtime.unmount_mindmap_shell, }; } return runtime; })(); return window.__mnoteLeptosTiptapRuntimePromise; }; // Tiptap/legacy conversion helpers live in document-tiptap-conversion-runtime.js. const normalizeBridgeValue = (value) => { if (value instanceof Map) { const out = {}; for (const [key, item] of value.entries()) out[key] = normalizeBridgeValue(item); return out; } if (Array.isArray(value)) return value.map(normalizeBridgeValue); if (value && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBridgeValue(item)])); } return value; }; const normalizeEnvelopePayload = (event) => { const detail = normalizeBridgeValue(event?.detail); if (!detail || typeof detail !== 'object') return null; const payload = detail.payload && typeof detail.payload === 'object' ? detail.payload : detail; return payload && typeof payload === 'object' ? payload : null; }; const setStatus = (runtime, status, message) => { runtime.root.setAttribute('data-runtime-editor-status', status); if (message) { runtime.root.setAttribute('data-runtime-editor-error', message); } else { runtime.root.removeAttribute('data-runtime-editor-error'); } if (runtime.observability instanceof HTMLElement) { runtime.observability.setAttribute('data-editor-host-status', status); runtime.observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island'); } }; const pageAggregateUrl = (bootstrap) => { const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin); url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder'); if (bootstrap.workspaceId) url.searchParams.set('workspaceId', bootstrap.workspaceId); if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri); return url; }; const pageAggregateUrlFromDescriptor = (descriptor) => { const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); url.searchParams.set('sourceKind', descriptor.sourceKind || 'convex_workspace'); if (descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); return url; }; const buildBootstrapFromAggregate = (aggregate, descriptor, paneRole) => ({ schema: 'mnote.editor_bootstrap.v1', documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId, workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '', paneRole, sourceKind: descriptor.sourceKind || 'convex_workspace', rootUri: descriptor.rootUri || '', pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__', saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder' ? '/api/page-body/write' : '/api/documents/save', titleEndpoint: '/api/documents/title', editorHostKind: 'leptos_tiptap_island', }); const syncPageAggregateScript = (session, aggregate) => { if (!session || !aggregate) return; const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__'; const node = document.getElementById(scriptId); if (!node) return; try { node.textContent = JSON.stringify(aggregate); node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now())); } catch (error) { console.warn('mnote Page Aggregate script 同步失败', error); } }; const documentSessionRegistry = new Map(); const localFolderEventRegistry = new Map(); const paneViewRegistry = new Map(); const resourceTabRegistry = new Map(); const resourceTabMru = { primary: [], secondary: [] }; const resourceTabMruMax = 20; const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded'; let nextViewId = 1; const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; const SESSION_RELEASE_DELAY_MS = 1200; const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1'; const mindmapHost = createMindmapHostRuntime({ loadRuntime, paneViewRegistry, unmountEditorViewBinding: (...args) => unmountEditorViewBinding(...args), pushUrlState, rootSelector: ROOT_SELECTOR, currentDocumentId: () => currentWebShellDocumentId(), }); const ensureLocalFolderSelfChangeSuppressions = () => { const now = Date.now(); const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map ? window.__mnoteLocalFolderSelfChangeSuppressions : new Map(); window.__mnoteLocalFolderSelfChangeSuppressions = map; try { const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : ''; const parsed = raw ? JSON.parse(raw) : null; if (parsed && typeof parsed === 'object') { Object.entries(parsed).forEach(([documentId, expiresAt]) => { const doc = String(documentId || '').trim(); const expiry = Number(expiresAt || 0); if (doc && Number.isFinite(expiry) && expiry > now) { map.set(doc, expiry); } else if (doc) { map.delete(doc); delete parsed[documentId]; } }); if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed)); } } catch (_) {} return map; }; const parseLocalFolderEventPayload = (event) => { try { return JSON.parse(String(event?.data || '{}')); } catch (_) { return null; } }; const shouldSuppressLocalFolderSelfChange = (documentId, eventKind) => { const doc = String(documentId || '').trim(); if (!doc) return false; const kind = String(eventKind || ''); const suppressibleSelfWrite = kind.includes('Create') || kind.includes('Metadata') || kind.includes('Modify(Data') || kind.includes('Modify(Any') || kind.includes('Modify(Name'); if (!suppressibleSelfWrite) return false; const suppressions = ensureLocalFolderSelfChangeSuppressions(); if (!suppressions || typeof suppressions.get !== 'function') return false; const expiresAt = Number(suppressions.get(doc) || 0); if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false; if (Date.now() > expiresAt) { if (typeof suppressions.delete === 'function') suppressions.delete(doc); return false; } return true; }; const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => { if (!session || session.sourceKind !== 'local_folder') return; const doc = String(session.documentId || '').trim(); if (!doc) return; const suppressions = ensureLocalFolderSelfChangeSuppressions(); if (!suppressions || typeof suppressions.set !== 'function') return; const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000); suppressions.set(doc, expiresAt); try { if (!window.sessionStorage) return; const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY); const parsed = raw ? JSON.parse(raw) : {}; const next = parsed && typeof parsed === 'object' ? parsed : {}; next[doc] = expiresAt; window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next)); } catch (_) {} }; const normalizeSessionSourceKind = (bootstrap) => { const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; return value || 'convex_workspace'; }; const buildDocumentSessionKey = (bootstrap) => { const sourceKind = normalizeSessionSourceKind(bootstrap); const scope = sourceKind === 'local_folder' ? String(bootstrap?.rootUri || '').trim() : String(bootstrap?.workspaceId || '').trim(); return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`; }; const sessionViews = (session) => Array.from(session.views.values()); const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`; const detachSessionFromLocalFolderChannel = (session) => { const channel = session.localFolderChannel; if (!channel) return; channel.sessions.delete(session.key); if (channel.sessions.size === 0) { try { channel.eventSource.close(); } catch (_) { // noop } localFolderEventRegistry.delete(channel.key); } session.localFolderChannel = null; }; const releaseDocumentSession = (session) => { if (session.saveTimer) { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } if (session.externalRefreshTimer) { window.clearTimeout(session.externalRefreshTimer); session.externalRefreshTimer = 0; } if (session.releaseTimer) { window.clearTimeout(session.releaseTimer); session.releaseTimer = 0; } detachSessionFromLocalFolderChannel(session); if (documentSessionRegistry.get(session.key) === session) { documentSessionRegistry.delete(session.key); } }; const scheduleDocumentSessionRelease = (session) => { if (session.views.size > 0) return; if (session.releaseTimer) window.clearTimeout(session.releaseTimer); session.releaseTimer = window.setTimeout(() => { session.releaseTimer = 0; if (session.views.size === 0) { releaseDocumentSession(session); } }, SESSION_RELEASE_DELAY_MS); }; const cancelDocumentSessionRelease = (session) => { if (!session.releaseTimer) return; window.clearTimeout(session.releaseTimer); session.releaseTimer = 0; }; window.__mnoteDebugDocumentSessions = { snapshot: () => ({ sessionCount: documentSessionRegistry.size, sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({ key, documentId: session.documentId, sourceKind: session.sourceKind, rootUri: session.rootUri, workspaceId: session.workspaceId, viewCount: session.views.size, status: session.status, conflictDetectionKey: session.conflictDetectionKey || '', lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '', lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null, dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), })), localFolderChannelCount: localFolderEventRegistry.size, localFolderRoots: Array.from(localFolderEventRegistry.keys()), }), }; const currentEditorText = (view) => { const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); return editor?.textContent || ''; }; const findScrollableEditorContainer = (view) => { let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror'); while (current instanceof HTMLElement) { if (current.scrollHeight > current.clientHeight + 8) { return current; } current = current.parentElement; } return null; }; const preserveViewScrollPosition = (view, operation) => { const scrollable = findScrollableEditorContainer(view); const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null; const viewportTop = window.scrollY; const viewportLeft = window.scrollX; operation(); const restore = () => { const current = findScrollableEditorContainer(view); if (current instanceof HTMLElement && scrollTop != null) { current.scrollTop = scrollTop; } if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) { window.scrollTo({ top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY, left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX, behavior: 'auto', }); } }; window.requestAnimationFrame(() => { restore(); window.setTimeout(restore, 0); window.setTimeout(restore, 80); window.setTimeout(restore, 180); window.setTimeout(restore, 320); }); }; const dispatchRuntimeCommand = (view, payload, source) => { view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(COMMAND_EVENT, { bubbles: true, detail: { protocol: BRIDGE_PROTOCOL, runtime: 'mnote-leptos-tiptap-spike', version: '1.1.0', source: source || 'mnote-web-document-session', event: COMMAND_EVENT, payload, }, })); }; const clearEmbeddedLocalDraft = (runtimeDescriptor) => { if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return; try { const storage = window.localStorage; const base = 'mnote.leptos-tiptap-spike.document'; const keys = [ `${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`, `${base}:${runtimeDescriptor.bootstrap.documentId}`, ]; for (const key of keys) storage.removeItem(key); } catch (error) { console.warn('mnote local folder 草稿清理失败', error); } }; const setSessionStatus = (session, status, message) => { session.status = status; session.error = message || null; sessionViews(session).forEach((view) => { setStatus(view.runtimeDescriptor, status, message); }); }; const dispatchSessionContentToView = (session, view, source) => { view.suppressedSerialized = session.currentSerialized; view.lastKnownSerialized = session.currentSerialized; preserveViewScrollPosition(view, () => { dispatchRuntimeCommand(view, { command: 'replaceContent', documentId: session.documentId, workspaceId: session.workspaceId, title: session.title, content: session.currentTiptapDocument, revision: session.revision, conflictDetectionKey: session.conflictDetectionKey, readOnly: session.readOnly, editable: !session.readOnly, }, source || 'mnote-web-document-session-sync'); }); }; const dispatchSessionMetaToView = (session, view, source) => { dispatchRuntimeCommand(view, { command: 'replaceContent', documentId: session.documentId, workspaceId: session.workspaceId, title: session.title, revision: session.revision, conflictDetectionKey: session.conflictDetectionKey, readOnly: session.readOnly, editable: !session.readOnly, }, source || 'mnote-web-document-session-meta'); }; const syncSessionMetaToViews = (session) => { sessionViews(session).forEach((view) => { if (view.mountId != null) dispatchSessionMetaToView(session, view); }); }; const broadcastSessionContent = (session, sourceView) => { sessionViews(session).forEach((view) => { if (sourceView && view.id === sourceView.id) return; if (view.mountId == null) return; dispatchSessionContentToView(session, view); }); }; const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); const sessionPlainText = (session) => { const liveText = sessionViews(session) .map((view) => normalizePlainText(currentEditorText(view))) .find((text) => text); if (liveText) return liveText; return normalizePlainText(flattenText(session.currentTiptapDocument)); }; const sessionHasRecentExternalSignal = (session) => ( session.sourceKind === 'local_folder' && Number.isFinite(session.lastExternalChangeSignalAt) && session.lastExternalChangeSignalAt > 0 && (Date.now() - session.lastExternalChangeSignalAt) < 1500 ); const sessionHasRecentLocalInput = (session) => ( Number.isFinite(session.lastUserInputAt) && session.lastUserInputAt > 0 && (Date.now() - session.lastUserInputAt) < 1500 ); const fetchLatestSessionAggregate = async (session) => { const response = await fetch(pageAggregateUrl({ documentId: session.documentId, sourceKind: session.sourceKind, workspaceId: session.workspaceId, rootUri: session.rootUri, }).toString(), { cache: 'no-store', headers: { accept: 'application/json' }, }); if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status); const payload = await response.json(); const nextAggregate = payload?.result; if (!nextAggregate || typeof nextAggregate !== 'object') { throw new Error('conflict_latest_missing_aggregate'); } return nextAggregate; }; const fetchLatestResourceSnapshot = async (session) => { const url = new URL('/api/local-folder/resource/read', window.location.origin); url.searchParams.set('rootUri', session.rootUri || ''); url.searchParams.set('path', session.resourcePath || ''); const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' }, }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status); const nextResource = payload.result; if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot'); return nextResource; }; const aggregatePlainText = (aggregate) => { const body = aggregate?.body || {}; return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim(); }; const resourceSnapshotPlainText = (snapshot) => ( String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '') .replace(/\n{3,}/g, '\n\n') .trim() ); const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => { if (!session || session.sourceKind !== 'local_folder') return false; if (!sessionHasRecentExternalSignal(session)) return false; if (!sessionPlainText(session)) return false; return !aggregatePlainText(nextAggregate); }; const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); const conflictEnvelopeFromResponse = (payload) => ( payload?.error?.details?.conflict || payload?.details?.conflict || null ); const conflictSourceLabel = (session) => { if (!session) return ''; if (session.lastExternalWriteSource === 'mnote-hermes-tool') { const runId = String(session.lastExternalWriteRunId || '').trim(); return runId ? `agent run ${runId}` : 'agent run'; } return '本地文件变更'; }; const conflictMessageFromEnvelope = (session, envelope) => { const baseMessage = envelope?.message || externalConflictMessage; const sourceLabel = conflictSourceLabel(session); return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage; }; const ensureSessionConflictEnvelope = (session, message) => { if (!session) return null; if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope; const envelope = { code: 'local_markdown_external_change', documentId: session.documentId, rootUri: session.rootUri || null, currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null, editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null, externalActor: session.lastExternalWriteSource || null, dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null, message: message || externalConflictMessage, suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'], }; session.lastExternalConflictEnvelope = envelope; return envelope; }; const clearSessionConflictSurface = (session) => { var _rt_ = window.__mnoteDocumentConflictPanelRuntime; if (_rt_ && typeof _rt_.clearSessionConflictSurface === 'function') { _rt_.clearSessionConflictSurface(session, { sessionViews: sessionViews }); return; } sessionViews(session).forEach((view) => { const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; if (!(host instanceof HTMLElement)) return; host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove()); }); }; const applyAggregateSnapshotToSession = (session, nextAggregate, source) => { const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); const nextSerialized = JSON.stringify(nextTiptapDocument); const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); session.title = nextAggregate?.head?.title || session.title; session.currentTiptapDocument = nextTiptapDocument; session.currentSerialized = nextSerialized; session.lastPersistedSerialized = nextSerialized; session.revision = nextRevision; session.conflictDetectionKey = nextConflictKey; session.lastExternalConflictDetectionKey = nextConflictKey || ''; session.readOnly = Boolean(nextPermissions.readOnly); session.dirty = false; session.hasExternalConflict = false; session.externalChangePending = false; session.lastUserInputAt = 0; clearSessionConflictSurface(session); sessionViews(session).forEach((view) => { if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved'); }); setSessionStatus(session, 'synced-external-change'); }; const applyResourceSnapshotToSession = (session, nextResource, source) => { const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); const nextSerialized = JSON.stringify(nextTiptapDocument); session.currentTiptapDocument = nextTiptapDocument; session.currentSerialized = nextSerialized; session.lastPersistedSerialized = nextSerialized; if (nextConflictKey) { session.conflictDetectionKey = nextConflictKey; session.lastExternalConflictDetectionKey = nextConflictKey; session.fileVersion = nextConflictKey; } session.dirty = false; session.hasExternalConflict = false; session.externalChangePending = false; session.lastUserInputAt = 0; clearSessionConflictSurface(session); sessionViews(session).forEach((view) => { if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved'); }); setSessionStatus(session, 'synced-external-change'); }; const openConflictDiffPanel = async (session, panel) => { const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]'); if (!(diffPanel instanceof HTMLElement)) return; diffPanel.hidden = false; diffPanel.replaceChildren(); const loading = document.createElement('div'); loading.className = 'mnote-conflict-diff-status'; loading.textContent = '正在读取磁盘版本...'; diffPanel.appendChild(loading); try { const latest = session.sessionKind === 'resource' ? await fetchLatestResourceSnapshot(session) : await fetchLatestSessionAggregate(session); const latestText = session.sessionKind === 'resource' ? resourceSnapshotPlainText(latest) : aggregatePlainText(latest); diffPanel.replaceChildren(); var _dpRT_ = window.__mnoteDocumentConflictPanelRuntime; if (_dpRT_ && typeof _dpRT_.populateSessionConflictDiffPanel === 'function') { _dpRT_.populateSessionConflictDiffPanel(diffPanel, { currentText: sessionPlainText(session) || '(当前编辑器为空)', diskText: latestText || '(磁盘版本为空)', currentMergeText: sessionPlainText(session) || '', diskMergeText: latestText || '', mergeDefaultText: sessionPlainText(session) || latestText || '', }, { onUseCurrent: function() { return sessionPlainText(session) || ''; }, onUseDisk: function() { return latestText || ''; }, onSaveMerge: function(mergeText) { writeMergedConflictResult(session, panel, mergeText).catch((error) => { setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); }); }, }); } else { const current = document.createElement('pre'); current.setAttribute('data-testid', 'mnote-conflict-current-text'); current.textContent = sessionPlainText(session) || '(当前编辑器为空)'; const disk = document.createElement('pre'); disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); disk.textContent = latestText || '(磁盘版本为空)'; const currentTitle = document.createElement('h3'); currentTitle.textContent = '当前编辑器版本'; const diskTitle = document.createElement('h3'); diskTitle.textContent = '磁盘版本'; const currentBox = document.createElement('section'); currentBox.append(currentTitle, current); const diskBox = document.createElement('section'); diskBox.append(diskTitle, disk); const mergeTitle = document.createElement('h3'); mergeTitle.textContent = '合并结果'; const mergeText = document.createElement('textarea'); mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); mergeText.value = sessionPlainText(session) || latestText || ''; const mergeActions = document.createElement('div'); mergeActions.className = 'mnote-conflict-actions'; const useCurrent = document.createElement('button'); useCurrent.type = 'button'; useCurrent.textContent = '使用当前版本'; useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current'); const useDisk = document.createElement('button'); useDisk.type = 'button'; useDisk.textContent = '使用磁盘版本'; useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk'); const saveMerge = document.createElement('button'); saveMerge.type = 'button'; saveMerge.textContent = '写回合并结果'; saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save'); mergeActions.append(useCurrent, useDisk, saveMerge); const mergeBox = document.createElement('section'); mergeBox.className = 'mnote-conflict-merge-box'; mergeBox.append(mergeTitle, mergeText, mergeActions); diffPanel.append(currentBox, diskBox, mergeBox); useCurrent.addEventListener('click', () => { mergeText.value = sessionPlainText(session) || ''; }); useDisk.addEventListener('click', () => { mergeText.value = latestText || ''; }); saveMerge.addEventListener('click', () => { writeMergedConflictResult(session, panel, mergeText.value).catch((error) => { setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); }); }); } } catch (error) { loading.textContent = error instanceof Error ? error.message : String(error); diffPanel.replaceChildren(loading); } }; const acceptDiskVersion = async (session) => { var _RT_ = window.__mnoteDocumentConflictPanelRuntime; if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') { return _RT_.acceptDiskVersion(session, { setSessionStatus: setSessionStatus, fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, applyResourceSnapshotToSession: applyResourceSnapshotToSession, fetchLatestSessionAggregate: fetchLatestSessionAggregate, applyAggregateSnapshotToSession: applyAggregateSnapshotToSession, }); } // inline fallback setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); if (session.sessionKind === 'resource') { const latestResource = await fetchLatestResourceSnapshot(session); applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk'); return; } const latest = await fetchLatestSessionAggregate(session); applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); }; const keepCurrentEditorVersion = async (session) => { var _RT_ = window.__mnoteDocumentConflictPanelRuntime; if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') { return _RT_.keepCurrentEditorVersion(session, { setSessionStatus: setSessionStatus, fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, fetchLatestSessionAggregate: fetchLatestSessionAggregate, sessionViews: sessionViews, currentEditorText: currentEditorText, normalizePlainText: normalizePlainText, hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom, textToTiptapDocument: textToTiptapDocument, conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, clearSessionConflictSurface: clearSessionConflictSurface, persistSession: persistSession, }); } // inline fallback setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); const latest = session.sessionKind === 'resource' ? await fetchLatestResourceSnapshot(session) : await fetchLatestSessionAggregate(session); const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; if (hydrateView) { const liveText = normalizePlainText(currentEditorText(hydrateView)); if (liveText) { session.currentTiptapDocument = hydrateMindmapAttrsFromDom( textToTiptapDocument(liveText), hydrateView.runtimeDescriptor.root, ); } session.currentSerialized = JSON.stringify(session.currentTiptapDocument); } const nextKey = session.sessionKind === 'resource' ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() : conflictDetectionKeyFromBody(latest.body || {}); if (nextKey) { session.conflictDetectionKey = nextKey; session.lastExternalConflictDetectionKey = nextKey; } session.hasExternalConflict = false; session.externalChangePending = false; session.saving = false; session.dirty = true; clearSessionConflictSurface(session); await persistSession(session); }; const writeMergedConflictResult = async (session, panel, mergedText) => { var _RT_ = window.__mnoteDocumentConflictPanelRuntime; if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') { return _RT_.writeMergedConflictResult(session, panel, mergedText, { setSessionStatus: setSessionStatus, fetchLatestResourceSnapshot: fetchLatestResourceSnapshot, fetchLatestSessionAggregate: fetchLatestSessionAggregate, conflictDetectionKeyFromBody: conflictDetectionKeyFromBody, textToTiptapDocument: textToTiptapDocument, persistSession: persistSession, }); } // inline fallback setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); const latest = session.sessionKind === 'resource' ? await fetchLatestResourceSnapshot(session) : await fetchLatestSessionAggregate(session); const nextKey = session.sessionKind === 'resource' ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() : conflictDetectionKeyFromBody(latest.body || {}); if (nextKey) { session.conflictDetectionKey = nextKey; session.lastExternalConflictDetectionKey = nextKey; } session.currentTiptapDocument = textToTiptapDocument(String(mergedText || '')); session.currentSerialized = JSON.stringify(session.currentTiptapDocument); session.hasExternalConflict = false; session.externalChangePending = false; session.saving = false; session.dirty = true; if (panel && typeof panel.remove === 'function') panel.remove(); await persistSession(session); }; const runSessionConflictAction = (action, onError) => { const runtime = window.__mnoteDocumentConflictPanelRuntime; if (runtime && typeof runtime.runSessionConflictAction === 'function') { runtime.runSessionConflictAction(action, { onError }); return; } try { const result = action(); if (result && typeof result.then === 'function') result.catch(onError); } catch (error) { onError(error); } }; const renderSessionConflictSurface = (session, message) => { clearSessionConflictSurface(session); sessionViews(session).forEach((view) => { const runtime = window.__mnoteDocumentConflictPanelRuntime; const handleConflictActionError = (error) => { setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error)); renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error)); }; let panel = null; if (runtime && typeof runtime.createSessionConflictPanel === 'function') { panel = runtime.createSessionConflictPanel(session, message, { externalConflictMessage, conflictSourceLabel, onAcceptDisk: () => { runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); }, onKeepCurrent: () => { runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); }, onOpenDiff: (_session, createdPanel) => { openConflictDiffPanel(session, createdPanel); }, }); } if (!(panel instanceof HTMLElement)) { panel = document.createElement('section'); panel.className = 'mnote-editor-conflict-panel'; panel.setAttribute('data-testid', 'mnote-editor-conflict-panel'); panel.setAttribute('role', 'status'); panel.setAttribute('aria-live', 'polite'); const heading = document.createElement('h2'); heading.textContent = '文件冲突'; const text = document.createElement('p'); text.textContent = message || externalConflictMessage; const meta = document.createElement('div'); meta.className = 'mnote-conflict-meta'; const fileLabel = session.sessionKind === 'resource' ? `${session.rootUri || ''}/${session.resourcePath || session.documentId}` : (session.rootUri || session.documentId); meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; const actions = document.createElement('div'); actions.className = 'mnote-conflict-actions'; const acceptDisk = document.createElement('button'); acceptDisk.type = 'button'; acceptDisk.textContent = '接受磁盘版本'; acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk'); const keepCurrent = document.createElement('button'); keepCurrent.type = 'button'; keepCurrent.textContent = '保留当前编辑器版本'; keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current'); const openDiff = document.createElement('button'); openDiff.type = 'button'; openDiff.textContent = '打开 diff'; openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff'); actions.append(acceptDisk, keepCurrent, openDiff); const diffPanel = document.createElement('div'); diffPanel.className = 'mnote-conflict-diff-panel'; diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel'); diffPanel.hidden = true; panel.append(heading, text, meta, actions, diffPanel); acceptDisk.addEventListener('click', () => { runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError); }); keepCurrent.addEventListener('click', () => { runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError); }); openDiff.addEventListener('click', () => { openConflictDiffPanel(session, panel); }); } if (runtime && typeof runtime.mountSessionConflictPanel === 'function') { if (runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)) { return; } } const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root; if (!(host instanceof HTMLElement)) return; const header = host.querySelector('.document-shell-header'); if (header && header.parentNode) { header.parentNode.insertBefore(panel, header.nextSibling); } else { host.prepend(panel); } }); }; const markSessionExternalConflict = (session, message) => { session.externalChangePending = false; ensureSessionConflictEnvelope(session, message); session.hasExternalConflict = true; if (session.saveTimer) { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } const nextMessage = message || externalConflictMessage; setSessionStatus(session, 'external-change-conflict', nextMessage); renderSessionConflictSurface(session, nextMessage); }; const queueSessionSave = (session) => { if (session.readOnly || session.hasExternalConflict) return; if (session.saveTimer) window.clearTimeout(session.saveTimer); setSessionStatus(session, 'dirty'); session.saveTimer = window.setTimeout(() => { session.saveTimer = 0; void persistSession(session); }, 650); }; const persistSession = async (session) => { if (session.readOnly || session.saving || session.hasExternalConflict) return; const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; if (hydrateView) { hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root); session.currentSerialized = JSON.stringify(session.currentTiptapDocument); } const serialized = session.currentSerialized; if (!session.dirty && serialized === session.lastPersistedSerialized) { setSessionStatus(session, 'saved'); return; } session.saving = true; setSessionStatus(session, 'saving'); try { const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); const content = legacyBlocksFromEditorDocument(editorDocument); const saveEndpoint = session.saveEndpoint || '/api/documents/save'; const savePayload = session.sessionKind === 'resource' ? { rootUri: session.rootUri, path: session.resourcePath, expectedFileVersion: session.conflictDetectionKey, contentFormat: 'editorBlocks', editorSource: 'tiptap-resource-tab', editorDocument, content, tiptapDocument: session.currentTiptapDocument, blockCount: editorDocument.blocks.length, } : { documentId: session.documentId, workspaceId: session.workspaceId, sourceKind: session.sourceKind, rootUri: session.rootUri, revision: session.revision, expectedFileVersion: session.conflictDetectionKey, contentFormat: 'editorBlocks', editorSource: 'tiptap', editorDocument, content, tiptapDocument: session.currentTiptapDocument, blockCount: editorDocument.blocks.length, }; if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') { savePayload.conflictDetectionKey = session.conflictDetectionKey; } markLocalFolderSelfChangeSuppression(session); const response = await fetch(saveEndpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(savePayload), }); const result = await response.json().catch(() => null); if (!response.ok || !result || result.ok !== true) { const message = result?.error?.message || result?.message || `save_failed_${response.status}`; const conflictEnvelope = conflictEnvelopeFromResponse(result); if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') { session.saving = false; session.lastExternalConflictEnvelope = conflictEnvelope; markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); return; } throw new Error(message); } const saved = result.result || {}; if (Number.isInteger(saved.revision)) session.revision = saved.revision; if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) { session.conflictDetectionKey = saved.conflict_detection_key.trim(); } if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) { session.conflictDetectionKey = saved.conflictDetectionKey.trim(); } if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) { session.conflictDetectionKey = saved.fileVersion.trim(); } if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey; session.hasExternalConflict = false; session.externalChangePending = false; session.lastUserInputAt = 0; syncSessionMetaToViews(session); session.lastPersistedSerialized = serialized; session.saving = false; if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') { const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0]; if (primaryView) { const plainText = sessionPlainText(session); window.__mnoteRecordPageHistorySnapshot('save', { wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length, characterCount: plainText.replace(/\s/g, '').length, blockCount: editorDocument.blocks.length, todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length, todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length, }); } } if (session.currentSerialized === serialized) { session.dirty = false; setSessionStatus(session, 'saved'); } else { session.dirty = true; setSessionStatus(session, 'dirty'); queueSessionSave(session); } syncResourceSessionTabGuards(session); } catch (error) { session.saving = false; setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error)); syncResourceSessionTabGuards(session); } }; const scheduleSessionExternalRefresh = (session, source) => { if (!session || session.views.size === 0) return; if (session.externalRefreshTimer) return; session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change'; session.externalRefreshTimer = window.setTimeout(() => { const refreshSource = session.externalRefreshSource || 'mnote-web-external-change'; session.externalRefreshSource = ''; session.externalRefreshTimer = 0; void refreshSessionFromExternalChange(session, refreshSource); }, 120); }; const refreshSessionFromExternalChange = async (session, source) => { if (document.hidden) return; if (!session || session.views.size === 0) return; if (session.sourceKind === 'local_folder' && !session.rootUri) return; try { const response = await fetch(pageAggregateUrl({ documentId: session.documentId, sourceKind: session.sourceKind, workspaceId: session.workspaceId, rootUri: session.rootUri, }).toString(), { cache: 'no-store', headers: { accept: 'application/json' }, }); if (!response.ok) { if (session.sourceKind === 'local_folder') { markSessionExternalConflict(session, externalConflictMessage); } return; } const payload = await response.json(); const conflictEnvelope = conflictEnvelopeFromResponse(payload); if (response.status === 409 && conflictEnvelope) { session.lastExternalConflictEnvelope = conflictEnvelope; markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope)); return; } let nextAggregate = payload?.result; if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) { await delay(500); try { const retryAggregate = await fetchLatestSessionAggregate(session); if (aggregatePlainText(retryAggregate)) { nextAggregate = retryAggregate; } else { markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。'); return; } } catch (_retryError) { markSessionExternalConflict(session, externalConflictMessage); return; } } const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session); const nextSerialized = JSON.stringify(nextTiptapDocument); const contentChanged = nextSerialized !== session.currentSerialized; session.externalChangePending = false; if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; if (!contentChanged) return; } if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { markSessionExternalConflict(session, externalConflictMessage); return; } const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; syncPageAggregateScript(session, nextAggregate); session.title = nextAggregate?.head?.title || session.title; session.currentTiptapDocument = nextTiptapDocument; session.currentSerialized = nextSerialized; session.lastPersistedSerialized = session.currentSerialized; session.revision = nextRevision; session.conflictDetectionKey = nextConflictKey; session.lastExternalConflictDetectionKey = nextConflictKey || ''; session.readOnly = Boolean(nextPermissions.readOnly); session.dirty = false; session.hasExternalConflict = false; session.lastUserInputAt = 0; sessionViews(session).forEach((view) => { if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change'); }); setSessionStatus(session, 'synced-external-change'); } catch (error) { console.warn('mnote 页面外部更新检测失败', error); } }; const refreshSessionFromExternalFileChange = async (session) => { if (session.sourceKind !== 'local_folder') return; await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); }; const ensureLocalFolderEventChannel = (session) => { if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') { return; } const channelKey = localFolderEventChannelKey(session); let channel = localFolderEventRegistry.get(channelKey); if (!channel) { const url = new URL('/api/local-folder/events', window.location.origin); url.searchParams.set('rootUri', session.rootUri); url.searchParams.set('documentId', session.documentId); const eventSource = new EventSource(url.toString()); channel = { key: channelKey, rootUri: session.rootUri, documentId: session.documentId, eventSource, sessions: new Map(), }; eventSource.addEventListener('change', (event) => { const payload = parseLocalFolderEventPayload(event); if (!payload) return; Array.from(channel.sessions.values()).forEach((targetSession) => { if (!targetSession || targetSession.views.size === 0) return; const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; if (!documentId) return; const eventKind = String(payload.eventKind || ''); const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); if (documentId && !targetsCurrentDocument) return; if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); return; } if (targetsCurrentDocument && targetSession.saving) { targetSession.externalChangePending = false; targetSession.lastSelfSaveSignalAt = Date.now(); return; } targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) { markSessionExternalConflict(targetSession, externalConflictMessage); return; } scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); }); }); eventSource.onerror = () => { console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); }; localFolderEventRegistry.set(channelKey, channel); } channel.sessions.set(session.key, session); session.localFolderChannel = channel; }; const readTreePayloadData = (payload) => ( payload && typeof payload === 'object' ? (payload.data || payload.delta || payload) : null ); const readTreePayloadOverview = (payload) => ( payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object' ? payload.overview : null ); const readTreePayloadCursor = (payload) => { const raw = String(payload?.cursor || payload?.revision || '').trim(); if (!raw) return { id: '', createdAt: '', raw: '' }; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(), createdAt: String(parsed.createdAt || parsed.created_at || '').trim(), raw, }; } } catch (_) {} return { id: raw, createdAt: '', raw }; }; const treeRecordMatchesPayloadCursor = (record, payload) => { if (!record || typeof record !== 'object') return false; const cursor = readTreePayloadCursor(payload); if (!cursor.id && !cursor.createdAt && !cursor.raw) return false; const ids = [ record.id, record._id, record.command_log_id, record.commandLogId, record.domain_event_id, record.domainEventId, record.command_id, record.commandId, ].map((value) => String(value || '').trim()).filter(Boolean); if (cursor.id && ids.includes(cursor.id)) return true; const createdAt = String(record.created_at || record.createdAt || '').trim(); return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt); }; const treeRecordTargetsDocument = (record, documentId) => { if (!record || typeof record !== 'object' || !documentId) return false; const targetPageId = String(record.target_page_id || record.targetPageId || '').trim(); const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); if (targetPageId === documentId || aggregateId === documentId) return true; const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; if (!payload) return false; const streamDelta = payload.streamDelta || payload.stream_delta || null; const deltaDocumentId = streamDelta && typeof streamDelta === 'object' ? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim() : ''; return deltaDocumentId === documentId; }; const collectMindmapIdsFromTreeRecord = (record, documentId, out) => { if (!record || typeof record !== 'object' || !documentId) return; if (!treeRecordTargetsDocument(record, documentId)) return; const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim(); if (targetBlockId) out.add(targetBlockId); const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim(); const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim(); if (aggregateType === 'block' && aggregateId) out.add(aggregateId); const payload = record.payload && typeof record.payload === 'object' ? record.payload : null; const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null; const blockId = streamDelta && typeof streamDelta === 'object' ? String(streamDelta.blockId || streamDelta.block_id || '').trim() : ''; if (blockId) out.add(blockId); }; const collectMindmapIdsFromTreePayload = (payload, session) => { const ids = new Set(); if (!payload || typeof payload !== 'object' || !session?.documentId) return []; const kind = String(payload.kind || '').trim(); const data = readTreePayloadData(payload); if (kind === 'delta' && data && typeof data === 'object') { const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); if (!documentId || documentId === session.documentId) { const blockId = String(data.blockId || data.block_id || '').trim(); if (blockId) ids.add(blockId); const streamDelta = data.streamDelta || data.stream_delta || null; const streamBlockId = streamDelta && typeof streamDelta === 'object' ? String(streamDelta.blockId || streamDelta.block_id || '').trim() : ''; if (streamBlockId) ids.add(streamBlockId); } } if (kind === 'resync') { const overview = readTreePayloadOverview(payload); if (overview) { const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; commandLogs .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); domainEvents .filter((record) => treeRecordMatchesPayloadCursor(record, payload)) .forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids)); } } return Array.from(ids); }; const refreshMindmapRuntimesFromTreePayload = (payload, session) => { const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {}; collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => { const bridge = registry[mindmapId]; if (bridge && typeof bridge.refreshProjection === 'function') { void bridge.refreshProjection('mnote-web-tree-live'); } }); }; const treePayloadTargetsDocument = (payload, session) => { if (!payload || typeof payload !== 'object' || !session?.documentId) return false; if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false; const data = readTreePayloadData(payload); if (data && typeof data === 'object') { const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim(); if (documentId === session.documentId) return true; const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : []; if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true; } const overview = readTreePayloadOverview(payload); if (!overview) return false; const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : []; const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : []; return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId)) || domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId)); }; const handleTreeExternalChange = (event) => { const payload = event?.detail?.payload || event?.detail || null; if (!payload) return; Array.from(documentSessionRegistry.values()).forEach((session) => { if (session.sourceKind === 'local_folder') return; if (!treePayloadTargetsDocument(payload, session)) return; refreshMindmapRuntimesFromTreePayload(payload, session); session.lastExternalChangeSignalAt = Date.now(); session.externalChangePending = true; if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { markSessionExternalConflict(session, treeExternalConflictMessage); return; } scheduleSessionExternalRefresh(session, 'mnote-web-tree-live'); }); }; const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => { if (!session) return false; const doc = String(documentId || '').trim(); const workspace = String(workspaceId || '').trim(); if (doc && session.documentId !== doc) return false; if (workspace && session.workspaceId && session.workspaceId !== workspace) return false; return true; }; const refreshDocumentSessionsFromExternalWrite = (detail, source) => { const documentId = String(detail?.documentId || '').trim(); const workspaceId = String(detail?.workspaceId || '').trim(); let scheduled = 0; Array.from(documentSessionRegistry.values()).forEach((session) => { if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return; session.lastExternalChangeSignalAt = Date.now(); session.externalChangePending = true; session.lastExternalWriteSource = source || session.lastExternalWriteSource || ''; session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim(); session.lastExternalWriteDocumentId = documentId; if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage); return; } scheduled += 1; scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write'); }); return scheduled; }; window.addEventListener('tree:delta', handleTreeExternalChange); window.addEventListener('tree:resync', handleTreeExternalChange); window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); }); const createDocumentSession = (runtimeDescriptor) => { const pageBody = runtimeDescriptor.aggregate.body || {}; const permissions = runtimeDescriptor.aggregate.head?.permissions || {}; const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody); const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null; const keyRevision = revisionFromConflictKey(conflictDetectionKey); const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); const session = { key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), documentId: runtimeDescriptor.bootstrap.documentId, workspaceId: runtimeDescriptor.bootstrap.workspaceId, sourceKind, rootUri: runtimeDescriptor.bootstrap.rootUri, saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save', pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', latestAggregate: runtimeDescriptor.aggregate, title: runtimeDescriptor.aggregate.head?.title || '无标题', currentTiptapDocument: tiptapDocument, currentSerialized: JSON.stringify(tiptapDocument), lastPersistedSerialized: JSON.stringify(tiptapDocument), revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision, conflictDetectionKey, fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, lastExternalConflictDetectionKey: conflictDetectionKey || '', readOnly: Boolean(permissions.readOnly), dirty: false, saving: false, hasExternalConflict: false, externalChangePending: false, externalRefreshSource: '', lastExternalChangeSignalAt: 0, lastSelfSaveSignalAt: 0, lastUserInputAt: 0, status: 'booting', error: null, saveTimer: 0, externalRefreshTimer: 0, releaseTimer: 0, views: new Map(), localFolderChannel: null, }; ensureLocalFolderEventChannel(session); return session; }; const getOrCreateDocumentSession = (runtimeDescriptor) => { const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap); const existing = documentSessionRegistry.get(key); if (existing) { cancelDocumentSessionRelease(existing); return existing; } const session = createDocumentSession(runtimeDescriptor); documentSessionRegistry.set(key, session); return session; }; const updatePaneChrome = (runtimeDescriptor) => { const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); if (!(pane instanceof HTMLElement)) return; const aggregate = runtimeDescriptor.aggregate || {}; const bootstrap = runtimeDescriptor.bootstrap || {}; const title = aggregate?.head?.title || '无标题'; const documentId = bootstrap.documentId || aggregate?.identity?.documentId || ''; const workspaceId = bootstrap.workspaceId || aggregate?.identity?.workspaceId || ''; pane.setAttribute('data-pane-document-id', documentId); pane.setAttribute('data-pane-workspace-id', workspaceId); pane.setAttribute('data-pane-visible', 'true'); pane.removeAttribute('data-mnote-side-target'); pane.hidden = false; const shell = pane.querySelector('.document-shell'); if (shell instanceof HTMLElement) { shell.setAttribute('data-document-id', documentId); shell.setAttribute('data-workspace-id', workspaceId); const options = aggregate?.layout?.pageOptions || {}; shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout))); shell.setAttribute('data-page-small-text', String(Boolean(options.smallText))); shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal')); shell.setAttribute('data-page-font', String(options.pageFont || 'default')); shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers))); } pane.querySelectorAll('[data-page-title-input="true"]').forEach((node) => { if (!(node instanceof HTMLTextAreaElement)) return; node.value = title; node.setAttribute('data-document-id', documentId); node.setAttribute('data-workspace-id', workspaceId); node.setAttribute('data-title-last-saved', title); node.setAttribute('data-title-save-status', 'saved'); node.style.height = 'auto'; node.style.height = `${Math.max(48, node.scrollHeight)}px`; }); pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => { if (node instanceof HTMLElement) node.textContent = title; }); const pageTab = document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${runtimeDescriptor.paneRole}"]`); if (pageTab instanceof HTMLElement) { pageTab.setAttribute('data-document-id', documentId); pageTab.setAttribute('data-workspace-id', workspaceId); const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title'); if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title; } if (runtimeDescriptor.paneRole === 'primary') { document.body.dataset.documentId = documentId; document.body.dataset.mnoteShell = 'document'; delete document.body.dataset.mindmapId; document.title = title; const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', { detail: { documentId, workspaceId, title } })); document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { if (row instanceof HTMLElement) { row.setAttribute('data-active', 'false'); row.setAttribute('data-selected', 'false'); } }); const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&'); document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => { if (!(row instanceof HTMLElement)) return; if (row.getAttribute('data-shell-mode') === 'filetree') { row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`)); } else { row.setAttribute('data-active', 'true'); } }); } runtimeDescriptor.root.removeAttribute('data-mnote-object-editor'); runtimeDescriptor.root.removeAttribute('data-mnote-object-identity'); runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id'); runtimeDescriptor.root.removeAttribute('data-mnote-side-target-unsupported'); runtimeDescriptor.root.removeAttribute('data-mnote-side-target-asset-id'); }; const fetchPageAggregateForPane = async (descriptor) => { const response = await fetch(pageAggregateUrlFromDescriptor(descriptor).toString(), { cache: 'no-store', headers: { accept: 'application/json' }, }); if (!response.ok) throw new Error(`page_aggregate_failed_${response.status}`); const payload = await response.json(); if (!payload?.result) throw new Error('page_aggregate_missing_result'); return payload.result; }; const replacePaneDocument = async (paneRole, descriptor, options = {}) => { mindmapHost.unmountMindmapPane(paneRole); const runtime = await loadRuntime(); const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`); if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`); const previousView = paneViewRegistry.get(paneRole); if (previousView) { unmountEditorViewBinding(previousView); paneViewRegistry.delete(paneRole); } const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor); const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole); const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap }; updatePaneChrome(runtimeDescriptor); if (paneRole === 'secondary' && workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'true'); setSecondaryEditorHostVisible(true); const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; applyStoredSecondaryWidth(); } const session = getOrCreateDocumentSession(runtimeDescriptor); const view = createEditorViewBinding(session, runtime, runtimeDescriptor); const mountOptions = { documentId: session.documentId, workspaceId: session.workspaceId, title: session.title, content: session.currentTiptapDocument, revision: session.revision, conflictDetectionKey: session.conflictDetectionKey, readOnly: session.readOnly, editable: !session.readOnly, pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {}, }; setStatus(runtimeDescriptor, 'loading-assets'); clearEmbeddedLocalDraft(runtimeDescriptor); try { const mountId = runtime.mount(runtimeDescriptor.root, mountOptions); view.mountId = mountId; runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); setStatus(runtimeDescriptor, 'mounting-editor'); paneViewRegistry.set(paneRole, view); return view; } catch (error) { unmountEditorViewBinding(view); throw error; } }; const descriptorFromCurrentUrl = (paneRole, documentId, explicit = {}) => { const url = currentUrl(); if (paneRole === 'secondary') { return { documentId, workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace', rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '', }; } return { documentId, workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace', rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '', }; }; const updatePrimaryUrl = (descriptor, urlFromCaller) => { const url = urlFromCaller instanceof URL ? urlFromCaller : new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind); if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); pushUrlState(url); }; const setSecondaryEditorHostVisible = (visible) => { const host = document.querySelector('[data-testid="mnote-secondary-editor-tab-host"]'); if (host instanceof HTMLElement) host.hidden = !visible; }; const closeSecondaryPane = (url) => { const view = paneViewRegistry.get('secondary'); if (view) { unmountEditorViewBinding(view); paneViewRegistry.delete('secondary'); } const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); if (pane instanceof HTMLElement) { pane.hidden = true; pane.setAttribute('data-pane-visible', 'false'); pane.removeAttribute('data-mnote-side-target'); pane.removeAttribute('data-pane-document-id'); pane.removeAttribute('data-pane-workspace-id'); } const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`); if (root instanceof HTMLElement) { root.replaceChildren(); root.removeAttribute('data-mnote-side-target-unsupported'); root.removeAttribute('data-mnote-side-target-asset-id'); } Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => { if (normalizePaneRole(entry?.paneRole) !== 'secondary') return; if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { try { entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); } catch (error) { console.warn('mnote secondary mindmap resource tab unmount failed', error); } } if (entry.tab instanceof HTMLElement) entry.tab.remove(); if (entry.panel instanceof HTMLElement) entry.panel.remove(); resourceTabRegistry.delete(key); removeFromResourceTabMru('secondary', key); }); if (workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'false'); workspace.style.removeProperty('grid-template-columns'); } setSecondaryEditorHostVisible(false); const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); if (resizerNode instanceof HTMLElement) resizerNode.hidden = true; document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); replaceUrlState(url); }; const handleSessionChange = (session, view, event) => { const payload = normalizeEnvelopePayload(event); if (!payload) return; const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending; const recentExternalChange = sessionHasRecentExternalSignal(session); const recentLocalInput = sessionHasRecentLocalInput(session); const tiptapDocument = hydrateMindmapAttrsFromDom(toTiptapDocument( payload?.tiptapDocument || payload?.editorDocument || payload?.content, currentEditorText(view), ), view.runtimeDescriptor.root); const serialized = JSON.stringify(tiptapDocument); if (view.suppressedSerialized && view.suppressedSerialized === serialized) { view.suppressedSerialized = null; view.lastKnownSerialized = serialized; return; } const previousSerialized = session.currentSerialized; session.currentTiptapDocument = tiptapDocument; session.currentSerialized = serialized; view.lastKnownSerialized = serialized; if (typeof payload?.title === 'string' && payload.title.trim()) { session.title = payload.title.trim(); } if (payload?.meta && typeof payload.meta === 'object') { if (Number.isInteger(payload.meta.revision)) session.revision = payload.meta.revision; if (typeof payload.meta.conflictDetectionKey === 'string' && payload.meta.conflictDetectionKey.trim()) { const nextMetaConflictKey = payload.meta.conflictDetectionKey.trim(); if (conflictDetectionKeyBelongsToSession(session, nextMetaConflictKey)) { session.conflictDetectionKey = nextMetaConflictKey; } } if (typeof payload.meta.readOnly === 'boolean') { session.readOnly = payload.meta.readOnly; } } session.dirty = session.currentSerialized !== session.lastPersistedSerialized; syncResourceSessionTabGuards(session); if (serialized !== previousSerialized) { broadcastSessionContent(session, view); } if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) { markSessionExternalConflict(session, externalConflictMessage); return; } if (session.hasExternalConflict) { setSessionStatus(session, 'external-change-conflict', externalConflictMessage); return; } if (session.dirty) { queueSessionSave(session); } else { setSessionStatus(session, 'saved'); } }; const unmountEditorViewBinding = (view, options = {}) => { if (!view || view.disposed) return; view.disposed = true; const scheduleRelease = options.scheduleRelease !== false; const root = view.runtimeDescriptor.root; if (typeof view.disconnectObserver === 'function') { view.disconnectObserver(); view.disconnectObserver = null; } if (view.onReady) root.removeEventListener(READY_EVENT, view.onReady); if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError); if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange); if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave); if (view.onState) root.removeEventListener(STATE_EVENT, view.onState); if (view.onKeydown) root.removeEventListener('keydown', view.onKeydown, true); if (view.onInput) root.removeEventListener('input', view.onInput); if (view.disconnectSlashObserver) { view.disconnectSlashObserver(); view.disconnectSlashObserver = null; } clearSessionConflictSurface(view.session); if (view.mountId != null && typeof view.runtime?.unmount === 'function') { try { view.runtime.unmount(view.mountId); } catch (error) { console.warn('mnote editor view unmount failed', error); } } view.mountId = null; root.removeAttribute('data-runtime-mount-id'); if (view.session.views.get(view.id) === view) { view.session.views.delete(view.id); } if (scheduleRelease) { scheduleDocumentSessionRelease(view.session); } }; const observeEditorViewBinding = (view) => { if (typeof MutationObserver !== 'function' || !(document.body instanceof HTMLElement)) { return; } const observer = new MutationObserver(() => { if (!view.disposed && !view.runtimeDescriptor.root.isConnected) { unmountEditorViewBinding(view); } }); observer.observe(document.body, { childList: true, subtree: true }); view.disconnectObserver = () => observer.disconnect(); }; installGlobalSlashMenuPositioning(); const createEditorViewBinding = (session, runtime, runtimeDescriptor) => { cancelDocumentSessionRelease(session); const view = { id: nextViewId++, mountId: null, ready: false, disposed: false, suppressedSerialized: null, lastKnownSerialized: session.currentSerialized, runtime, runtimeDescriptor, session, disconnectObserver: null, onReady: null, onError: null, onChange: null, onSave: null, onState: null, onKeydown: null, onInput: null, disconnectSlashObserver: null, }; view.onReady = () => { view.ready = true; if (view.lastKnownSerialized !== session.currentSerialized) { dispatchSessionContentToView(session, view, 'mnote-web-document-session-ready-sync'); } if (session.status === 'dirty' || session.status === 'saving' || session.status === 'saved' || session.status === 'error' || session.status === 'external-change-conflict' || session.status === 'synced-external-change') { setStatus(runtimeDescriptor, session.status, session.error); } else { setStatus(runtimeDescriptor, 'ready'); } }; view.onError = (event) => { const payload = normalizeEnvelopePayload(event); setStatus(runtimeDescriptor, 'error', payload?.message || 'runtime_error'); }; view.onChange = (event) => { scheduleSlashMenuPosition(runtimeDescriptor.root); handleSessionChange(session, view, event); }; view.onSave = (event) => { handleSessionChange(session, view, event); }; view.onState = () => { scheduleSlashMenuPosition(runtimeDescriptor.root); }; view.onKeydown = (event) => { if (event.key === '/') scheduleSlashMenuPosition(runtimeDescriptor.root); }; view.onInput = (event) => { const target = event.target; if (!(target instanceof Element) || !target.closest('.ProseMirror')) return; session.lastUserInputAt = Date.now(); scheduleSlashMenuPosition(runtimeDescriptor.root); }; runtimeDescriptor.root.addEventListener(READY_EVENT, view.onReady); runtimeDescriptor.root.addEventListener(ERROR_EVENT, view.onError); runtimeDescriptor.root.addEventListener(CHANGE_EVENT, view.onChange); runtimeDescriptor.root.addEventListener(SAVE_EVENT, view.onSave); runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState); runtimeDescriptor.root.addEventListener('keydown', view.onKeydown, true); runtimeDescriptor.root.addEventListener('input', view.onInput); session.views.set(view.id, view); observeSlashMenuPosition(view); observeEditorViewBinding(view); return view; }; const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary'; const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`; const resourceTabHostNodes = (paneRole = 'primary') => { const role = normalizePaneRole(paneRole); return { paneRole: role, strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`), pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`), pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`), host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`), panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`), }; }; const resourceTabBadgeKind = (input, kind) => { const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); if (kind === 'mindmap') return 'mindmap'; if (kind === 'office') { if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt'; if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet'; return 'word'; } if (kind === 'pdf') return 'pdf'; if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code'; if (kind === 'image') return 'image'; return 'file'; }; const currentWebShellWorkspaceId = () => { try { return currentUrl().searchParams.get('workspaceId') || ''; } catch (_) { return ''; } }; const currentWebShellDocumentId = () => { const fromBody = document.body?.dataset?.documentId || ''; if (fromBody) return String(fromBody).trim(); const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim(); return ''; }; const normalizeResourceTabKind = (input) => { const kind = String(input?.kind || '').trim().toLowerCase(); const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind; if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office'; if (/\.pdf$/i.test(title)) return 'pdf'; if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image'; if (/\.(md|markdown)$/i.test(title)) return 'markdown'; if (/\.(txt|log)$/i.test(title)) return 'text'; if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code'; return 'file'; }; const resolveResourceOpen = (input = {}) => { const kind = normalizeResourceTabKind(input); const badgeKind = resourceTabBadgeKind(input, kind); const rawTarget = String(input?.openTarget || '').trim().toLowerCase(); const openTarget = rawTarget === 'new-window' || rawTarget === 'side' ? rawTarget : 'active-tab'; const editable = kind === 'markdown' || kind === 'text' || kind === 'code'; const defaultOpenMode = kind === 'office' && openTarget === 'active-tab' ? 'active-tab-iframe' : openTarget; const viewerUrl = kind === 'office' ? String(input?.officeUrl || input?.href || '').trim() : String(input?.href || '').trim(); return { editorKind: kind, badgeKind, defaultOpenMode, editable, viewerUrl, openTarget }; }; const touchResourceTabMru = (paneRole, key) => { const role = normalizePaneRole(paneRole); const id = String(key || '').trim(); if (!id) return; const list = resourceTabMru[role] || (resourceTabMru[role] = []); const index = list.indexOf(id); if (index >= 0) list.splice(index, 1); list.unshift(id); if (list.length > resourceTabMruMax) list.length = resourceTabMruMax; }; const setResourceTabLastActive = (key) => { const entry = resourceTabRegistry.get(String(key || '').trim()); if (entry?.session) entry.session.lastActiveAt = Date.now(); }; const removeFromResourceTabMru = (paneRole, key) => { const list = resourceTabMru[normalizePaneRole(paneRole)] || []; const index = list.indexOf(key); if (index >= 0) list.splice(index, 1); }; const lastActiveResourceTabKey = (paneRole = 'primary') => { const list = resourceTabMru[normalizePaneRole(paneRole)] || []; for (const key of list) { if (resourceTabRegistry.has(key)) return key; } return ''; }; const resourceTabCloseGuardReason = (session) => { if (!session) return ''; const hasUnsavedChanges = session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session) || (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized); if (hasUnsavedChanges) return 'dirty'; if (session.saving) return 'saving'; if (session.hasExternalConflict) return 'hasExternalConflict'; return ''; }; const syncResourceTabCloseGuard = (entry) => { if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return; const reason = resourceTabCloseGuardReason(entry.session); if (reason) { entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason); entry.tab.classList.add('is-close-guarded'); } else { entry.tab.removeAttribute(resourceTabCloseGuardAttribute); entry.tab.classList.remove('is-close-guarded'); } }; const syncResourceSessionTabGuards = (session) => { if (!session || session.sessionKind !== 'resource') return; resourceTabRegistry.forEach((entry) => { if (entry.session === session) syncResourceTabCloseGuard(entry); }); }; const openEditorsSnapshotEntry = (entry, key) => ({ objectIdentity: String(entry?.objectIdentity || key || '').trim(), title: String(entry?.title || '资源').trim() || '资源', kind: normalizeResourceTabKind(entry), badgeKind: entry?.tab instanceof HTMLElement ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim() : resourceTabBadgeKind(entry, entry?.kind), active: entry?.tab instanceof HTMLElement ? entry.tab.getAttribute('aria-selected') === 'true' : false, dirtyGuard: resourceTabCloseGuardReason(entry?.session), assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(), path: String(entry?.path || entry?.session?.resourcePath || '').trim(), }); const buildOpenEditorsSnapshot = () => { const pageEntries = ['primary', 'secondary'].map((paneRole) => { const nodes = resourceTabHostNodes(paneRole); const pageTitle = nodes.pageTab instanceof HTMLElement ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() : '页面'; return { objectIdentity: `page:${paneRole}`, paneRole, documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(), workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(), title: pageTitle || '页面', kind: 'page', badgeKind: 'code', active: nodes.pageTab instanceof HTMLElement ? nodes.pageTab.getAttribute('aria-selected') === 'true' : false, dirtyGuard: '', }; }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]')); const resources = []; resourceTabRegistry.forEach((entry, key) => { resources.push(openEditorsSnapshotEntry(entry, key)); }); const active = [...pageEntries, ...resources].find((entry) => entry.active); return { schema: 'mnote.open_editors_snapshot.v1', generatedAt: Date.now(), activeObjectIdentity: active?.objectIdentity || '', editors: [...pageEntries, ...resources], resourceEditors: resources, }; }; const syncOpenEditorsSnapshot = () => { const snapshot = buildOpenEditorsSnapshot(); window.__mnoteOpenEditorsSnapshot = snapshot; document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length)); document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || ''); window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot })); return snapshot; }; const showResourceTabCloseGuardNotice = (entry, reason) => { const nodes = resourceTabHostNodes(entry?.paneRole || 'primary'); const messages = { dirty: '当前资源有未保存的修改,保存完成后再关闭。', saving: '当前资源正在保存中,请稍后再关闭。', hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。', }; const message = messages[reason] || '当前资源暂时无法关闭。'; let notice = document.getElementById('mnote-resource-close-guard-notice'); if (!notice) { notice = document.createElement('div'); notice.id = 'mnote-resource-close-guard-notice'; notice.className = 'mnote-close-guard-notice'; notice.setAttribute('role', 'status'); notice.setAttribute('aria-live', 'polite'); notice.setAttribute('data-mnote-resource-close-guard', ''); const parent = nodes.strip?.parentNode; if (parent instanceof HTMLElement) { const panels = parent.querySelector('.mnote-main-tab-panels'); if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels); else parent.append(notice); } } notice.textContent = `${entry?.title || '资源'}:${message}`; notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked'); notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`; if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer); notice._mnoteHideTimer = window.setTimeout(() => { notice.classList.add('is-hiding'); window.setTimeout(() => { if (notice.parentNode) notice.remove(); }, 260); }, 4000); }; const cssSafe = (value) => { const text = String(value || ''); if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text); return text.replace(/["\\]/g, '\\$&'); }; const syncActiveResourceFileTreeRow = (activeResource) => { document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => { if (row instanceof HTMLElement) row.setAttribute('data-active', 'false'); }); const entry = activeResource ? resourceTabRegistry.get(activeResource) : null; if (!entry) return; const assetId = String(entry.assetId || entry.session?.assetId || '').trim(); const path = String(entry.path || entry.session?.resourcePath || '').trim(); const identity = String(entry.objectIdentity || activeResource || '').trim(); if (assetId) { const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`); if (row instanceof HTMLElement) { row.setAttribute('data-active', 'true'); return; } } const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]'); for (const row of rows) { if (!(row instanceof HTMLElement)) continue; const objectIdentity = row.getAttribute('data-object-identity') || ''; if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) { row.setAttribute('data-active', 'true'); return; } } }; const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => { const role = normalizePaneRole(paneRole); if (role !== 'primary') return; const url = currentUrl(); if (activeResource) { const entry = resourceTabRegistry.get(activeResource); const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim(); if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`; url.searchParams.set('resourceTab', activeResource); } else { const documentId = currentWebShellDocumentId(); if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`; url.searchParams.delete('resourceTab'); } replaceUrlState(url); }; const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => { const activeResource = String(objectIdentity || '').trim(); const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null; const role = normalizePaneRole(activeEntry?.paneRole || paneRole); const nodes = resourceTabHostNodes(role); if (activeResource) { touchResourceTabMru(role, activeResource); setResourceTabLastActive(activeResource); } if (nodes.pageTab instanceof HTMLElement) { const activePage = !activeResource; nodes.pageTab.classList.toggle('is-active', activePage); nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false'); nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1'); } if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource); if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource; resourceTabRegistry.forEach((entry, key) => { if (normalizePaneRole(entry.paneRole) !== role) return; const active = key === activeResource; if (entry.tab instanceof HTMLElement) { entry.tab.classList.toggle('is-active', active); entry.tab.setAttribute('aria-selected', active ? 'true' : 'false'); entry.tab.setAttribute('tabindex', active ? '0' : '-1'); } if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active; syncResourceTabCloseGuard(entry); }); if (activeEntry) markIntendedSlashRoot(activeEntry); if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null; if (role === 'primary') syncActiveResourceFileTreeRow(activeResource); syncActiveResourceUrlState(activeResource, role); syncOpenEditorsSnapshot(); }; const bindMainEditorTabStrip = (paneRole = 'primary') => { const nodes = resourceTabHostNodes(paneRole); if (!(nodes.strip instanceof HTMLElement)) return; if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return; nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true'); const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]')) .filter((tab) => tab instanceof HTMLElement && tab.isConnected); const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true'); nodes.strip.addEventListener('keydown', (event) => { const tabs = collectTabs(); if (!tabs.length) return; const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement); const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs)); let targetIndex = -1; if (event.key === 'ArrowRight') { targetIndex = (baseIndex + 1) % tabs.length; } else if (event.key === 'ArrowLeft') { targetIndex = (baseIndex - 1 + tabs.length) % tabs.length; } else if (event.key === 'Home') { targetIndex = 0; } else if (event.key === 'End') { targetIndex = tabs.length - 1; } else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); const tab = tabs[baseIndex]; if (tab instanceof HTMLElement) tab.click(); return; } else { return; } event.preventDefault(); const target = tabs[targetIndex]; if (target instanceof HTMLElement) target.focus(); }); }; const bindMainEditorPageTab = (paneRole = 'primary') => { const role = normalizePaneRole(paneRole); const nodes = resourceTabHostNodes(role); if (!(nodes.pageTab instanceof HTMLElement)) return; bindMainEditorTabStrip(role); if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return; nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true'); nodes.pageTab.addEventListener('click', (event) => { const target = event.target; if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return; event.preventDefault(); activateMainEditorTab('', role); }); syncOpenEditorsSnapshot(); }; const closeResourceTab = (objectIdentity) => { const key = String(objectIdentity || '').trim(); const entry = resourceTabRegistry.get(key); if (!entry) return; const guardReason = resourceTabCloseGuardReason(entry.session); if (guardReason) { console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`); syncResourceTabCloseGuard(entry); showResourceTabCloseGuardNotice(entry, guardReason); return; } removeFromResourceTabMru(entry.paneRole || 'primary', key); if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { try { entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); } catch (error) { console.warn('mnote mindmap resource tab unmount failed', error); } } if (entry.tab instanceof HTMLElement) entry.tab.remove(); if (entry.panel instanceof HTMLElement) entry.panel.remove(); resourceTabRegistry.delete(key); const role = normalizePaneRole(entry.paneRole); const nextKey = lastActiveResourceTabKey(role); activateMainEditorTab(nextKey, role); const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes(role).pageTab; if (nextTab instanceof HTMLElement) nextTab.focus(); }; const markResourceTabError = (entry) => { if (!entry) return; entry.kind = 'error'; if (entry.tab instanceof HTMLElement) { entry.tab.setAttribute('data-mnote-tab-kind', 'error'); entry.tab.setAttribute('data-mnote-tab-badge-kind', 'file'); entry.tab.classList.add('is-error'); } if (entry.panel instanceof HTMLElement) { entry.panel.setAttribute('data-resource-kind', 'error'); entry.panel.innerHTML = '

资源打开失败

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

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

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

'; const text = placeholder.querySelector('p'); if (text) text.textContent = title; root.append(placeholder); } document.documentElement.setAttribute('data-mnote-side-target-unsupported', 'true'); document.documentElement.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || '')); replaceUrlState(url); return true; }; const openResourceInActiveTab = async (input = {}) => { const paneRole = normalizePaneRole(input.paneRole || input.targetPaneRole || 'primary'); if (paneRole === 'secondary') { if (workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'true'); applyStoredSecondaryWidth(); } setSecondaryEditorHostVisible(true); const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); if (pane instanceof HTMLElement) { pane.hidden = false; pane.setAttribute('data-pane-visible', 'true'); } } bindMainEditorPageTab(paneRole); const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim(); if (!objectIdentity) return false; const registryKey = resourceTabRegistryKey(paneRole, objectIdentity); if (paneRole === 'secondary') { resourceTabRegistry.forEach((entry, key) => { if (normalizePaneRole(entry.paneRole) !== paneRole) return; if (key === registryKey) return; releaseResourceTabEntryRuntime(entry); if (entry.tab instanceof HTMLElement) entry.tab.remove(); if (entry.panel instanceof HTMLElement) entry.panel.remove(); resourceTabRegistry.delete(key); removeFromResourceTabMru(paneRole, key); }); } const existing = resourceTabRegistry.get(registryKey); if (existing) { refreshExistingOfficeResourceTab(existing, input); activateMainEditorTab(registryKey, paneRole); return true; } const entry = createResourceTabDom({ ...input, objectIdentity, paneRole }); if (!entry) return false; resourceTabRegistry.set(registryKey, entry); activateMainEditorTab(registryKey, paneRole); try { if (entry.kind === 'mindmap') { await mindmapHost.openMindmapResourceTab(entry, input); } else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') { await openTiptapResourceTab(entry, input); } else { openPassiveResourceTab(entry, input); } activateMainEditorTab(registryKey, paneRole); return true; } catch (error) { console.warn('mnote resource tab 打开失败', error); markResourceTabError(entry); return true; } }; const mountPane = async (runtimeDescriptor) => { const runtime = await loadRuntime(); const session = getOrCreateDocumentSession(runtimeDescriptor); const view = createEditorViewBinding(session, runtime, runtimeDescriptor); const mountOptions = { documentId: session.documentId, workspaceId: session.workspaceId, title: session.title, content: session.currentTiptapDocument, revision: session.revision, conflictDetectionKey: session.conflictDetectionKey, readOnly: session.readOnly, editable: !session.readOnly, pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {}, }; setStatus(runtimeDescriptor, 'loading-assets'); clearEmbeddedLocalDraft(runtimeDescriptor); try { const mountId = runtime.mount(runtimeDescriptor.root, mountOptions); view.mountId = mountId; runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId)); runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); setStatus(runtimeDescriptor, 'mounting-editor'); paneViewRegistry.set(runtimeDescriptor.paneRole, view); } catch (error) { unmountEditorViewBinding(view); throw error; } }; window.__mnoteDocumentPaneRuntime = { openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { const id = typeof documentId === 'string' ? documentId.trim() : ''; if (!id) return false; const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); await replacePaneDocument('primary', descriptor); activateMainEditorTab('', 'primary'); updatePrimaryUrl(descriptor, url instanceof URL ? url : null); return true; }, openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => { const docId = typeof documentId === 'string' ? documentId.trim() : ''; const mapId = typeof mindmapId === 'string' ? mindmapId.trim() : ''; if (!docId || !mapId) return false; await mindmapHost.replacePrimaryPaneMindmap({ documentId: docId, mindmapId: mapId, workspaceId: typeof workspaceId === 'string' ? workspaceId.trim() : '', url, }); return true; }, openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { const id = typeof documentId === 'string' ? documentId.trim() : ''; if (!id) return false; const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri }); await replacePaneDocument('secondary', descriptor); activateMainEditorTab('', 'secondary'); if (url instanceof URL) replaceUrlState(url); return true; }, resolveResourceOpen: (input) => resolveResourceOpen(input), openResourceInActiveTab: openResourceInActiveTab, activatePageTab: ({ paneRole } = {}) => { const role = normalizePaneRole(paneRole || 'primary'); bindMainEditorPageTab(role); activateMainEditorTab('', role); return true; }, openResourceAsSideTarget: async (input = {}) => { return openResourceInActiveTab({ ...input, paneRole: 'secondary', openTarget: 'active-tab' }); }, closeSecondaryDocument: ({ url } = {}) => { closeSecondaryPane(url instanceof URL ? url : currentUrl()); return true; }, getOpenEditorsSnapshot: () => buildOpenEditorsSnapshot(), refreshDocument: async ({ documentId, workspaceId, source } = {}) => { const targets = Array.from(documentSessionRegistry.values()).filter((session) => ( sessionMatchesDocumentWorkspace(session, documentId, workspaceId) )); await Promise.all(targets.map((session) => refreshSessionFromExternalChange( session, source || 'mnote-web-programmatic-refresh', ))); return targets.length; }, refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => { const primary = paneViewRegistry.get('primary'); if (!primary?.session) return 0; if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0; await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh'); return 1; }, }; bindMainEditorPageTab('primary'); bindMainEditorPageTab('secondary'); window.addEventListener('pagehide', () => { Array.from(documentSessionRegistry.values()).forEach((session) => { sessionViews(session).forEach((view) => { unmountEditorViewBinding(view, { scheduleRelease: false }); }); releaseDocumentSession(session); }); }, { once: true }); if (paneRuntimes.length) { Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => { console.error('mnote multi-pane editor mount failed', error); }); } })();