Files
mnote/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js
T

2914 lines
132 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 mindmapPaneViewRegistry = new Map();
const resourceTabRegistry = new Map();
const resourceTabMru = { primary: [], secondary: [] };
const resourceTabMruMax = 20;
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
const SESSION_RELEASE_DELAY_MS = 1200;
const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1';
const ensureLocalFolderSelfChangeSuppressions = () => {
const now = Date.now();
const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map
? window.__mnoteLocalFolderSelfChangeSuppressions
: new Map();
window.__mnoteLocalFolderSelfChangeSuppressions = map;
try {
const raw = window.sessionStorage ? window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY) : '';
const parsed = raw ? JSON.parse(raw) : null;
if (parsed && typeof parsed === 'object') {
Object.entries(parsed).forEach(([documentId, expiresAt]) => {
const doc = String(documentId || '').trim();
const expiry = Number(expiresAt || 0);
if (doc && Number.isFinite(expiry) && expiry > now) {
map.set(doc, expiry);
} else if (doc) {
map.delete(doc);
delete parsed[documentId];
}
});
if (window.sessionStorage) window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(parsed));
}
} catch (_) {}
return map;
};
const unmountMindmapPane = (paneRole) => {
const view = mindmapPaneViewRegistry.get(paneRole);
if (!view) return;
mindmapPaneViewRegistry.delete(paneRole);
if (view.mountId != null && view.runtime && typeof view.runtime.unmount === 'function') {
try {
view.runtime.unmount(view.mountId);
} catch (error) {
console.warn('mnote mindmap pane unmount failed', error);
}
}
if (view.root instanceof HTMLElement) {
view.root.removeAttribute('data-runtime-mount-id');
view.root.removeAttribute('data-mnote-object-editor');
view.root.removeAttribute('data-mnote-object-identity');
view.root.removeAttribute('data-mnote-mindmap-id');
view.root.replaceChildren();
}
};
const parseLocalFolderEventPayload = (event) => {
try {
return JSON.parse(String(event?.data || '{}'));
} catch (_) {
return null;
}
};
const shouldSuppressLocalFolderSelfChange = (documentId, eventKind) => {
const doc = String(documentId || '').trim();
if (!doc) return false;
const kind = String(eventKind || '');
const suppressibleSelfWrite = kind.includes('Create')
|| kind.includes('Metadata')
|| kind.includes('Modify(Data')
|| kind.includes('Modify(Any')
|| kind.includes('Modify(Name');
if (!suppressibleSelfWrite) return false;
const suppressions = ensureLocalFolderSelfChangeSuppressions();
if (!suppressions || typeof suppressions.get !== 'function') return false;
const expiresAt = Number(suppressions.get(doc) || 0);
if (!Number.isFinite(expiresAt) || expiresAt <= 0) return false;
if (Date.now() > expiresAt) {
if (typeof suppressions.delete === 'function') suppressions.delete(doc);
return false;
}
return true;
};
const markLocalFolderSelfChangeSuppression = (session, ttlMs = 5000) => {
if (!session || session.sourceKind !== 'local_folder') return;
const doc = String(session.documentId || '').trim();
if (!doc) return;
const suppressions = ensureLocalFolderSelfChangeSuppressions();
if (!suppressions || typeof suppressions.set !== 'function') return;
const expiresAt = Date.now() + Math.max(1000, Number(ttlMs) || 5000);
suppressions.set(doc, expiresAt);
try {
if (!window.sessionStorage) return;
const raw = window.sessionStorage.getItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY);
const parsed = raw ? JSON.parse(raw) : {};
const next = parsed && typeof parsed === 'object' ? parsed : {};
next[doc] = expiresAt;
window.sessionStorage.setItem(LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY, JSON.stringify(next));
} catch (_) {}
};
const normalizeSessionSourceKind = (bootstrap) => {
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
return value || 'convex_workspace';
};
const buildDocumentSessionKey = (bootstrap) => {
const sourceKind = normalizeSessionSourceKind(bootstrap);
const scope = sourceKind === 'local_folder'
? String(bootstrap?.rootUri || '').trim()
: String(bootstrap?.workspaceId || '').trim();
return `${sourceKind}:${scope}:${String(bootstrap?.documentId || '').trim()}`;
};
const sessionViews = (session) => Array.from(session.views.values());
const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`;
const detachSessionFromLocalFolderChannel = (session) => {
const channel = session.localFolderChannel;
if (!channel) return;
channel.sessions.delete(session.key);
if (channel.sessions.size === 0) {
try {
channel.eventSource.close();
} catch (_) {
// noop
}
localFolderEventRegistry.delete(channel.key);
}
session.localFolderChannel = null;
};
const releaseDocumentSession = (session) => {
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
if (session.externalRefreshTimer) {
window.clearTimeout(session.externalRefreshTimer);
session.externalRefreshTimer = 0;
}
if (session.releaseTimer) {
window.clearTimeout(session.releaseTimer);
session.releaseTimer = 0;
}
detachSessionFromLocalFolderChannel(session);
if (documentSessionRegistry.get(session.key) === session) {
documentSessionRegistry.delete(session.key);
}
};
const scheduleDocumentSessionRelease = (session) => {
if (session.views.size > 0) return;
if (session.releaseTimer) window.clearTimeout(session.releaseTimer);
session.releaseTimer = window.setTimeout(() => {
session.releaseTimer = 0;
if (session.views.size === 0) {
releaseDocumentSession(session);
}
}, SESSION_RELEASE_DELAY_MS);
};
const cancelDocumentSessionRelease = (session) => {
if (!session.releaseTimer) return;
window.clearTimeout(session.releaseTimer);
session.releaseTimer = 0;
};
window.__mnoteDebugDocumentSessions = {
snapshot: () => ({
sessionCount: documentSessionRegistry.size,
sessions: Array.from(documentSessionRegistry.entries()).map(([key, session]) => ({
key,
documentId: session.documentId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
workspaceId: session.workspaceId,
viewCount: session.views.size,
status: session.status,
conflictDetectionKey: session.conflictDetectionKey || '',
lastExternalConflictDetectionKey: session.lastExternalConflictDetectionKey || '',
lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
})),
localFolderChannelCount: localFolderEventRegistry.size,
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
}),
};
const currentEditorText = (view) => {
const editor = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
return editor?.textContent || '';
};
const findScrollableEditorContainer = (view) => {
let current = view.runtimeDescriptor.root.querySelector('.editor-surface .ProseMirror');
while (current instanceof HTMLElement) {
if (current.scrollHeight > current.clientHeight + 8) {
return current;
}
current = current.parentElement;
}
return null;
};
const preserveViewScrollPosition = (view, operation) => {
const scrollable = findScrollableEditorContainer(view);
const scrollTop = scrollable instanceof HTMLElement ? scrollable.scrollTop : null;
const viewportTop = window.scrollY;
const viewportLeft = window.scrollX;
operation();
const restore = () => {
const current = findScrollableEditorContainer(view);
if (current instanceof HTMLElement && scrollTop != null) {
current.scrollTop = scrollTop;
}
if (Number.isFinite(viewportTop) || Number.isFinite(viewportLeft)) {
window.scrollTo({
top: Number.isFinite(viewportTop) ? viewportTop : window.scrollY,
left: Number.isFinite(viewportLeft) ? viewportLeft : window.scrollX,
behavior: 'auto',
});
}
};
window.requestAnimationFrame(() => {
restore();
window.setTimeout(restore, 0);
window.setTimeout(restore, 80);
window.setTimeout(restore, 180);
window.setTimeout(restore, 320);
});
};
const dispatchRuntimeCommand = (view, payload, source) => {
view.runtimeDescriptor.root.dispatchEvent(new CustomEvent(COMMAND_EVENT, {
bubbles: true,
detail: {
protocol: BRIDGE_PROTOCOL,
runtime: 'mnote-leptos-tiptap-spike',
version: '1.1.0',
source: source || 'mnote-web-document-session',
event: COMMAND_EVENT,
payload,
},
}));
};
const clearEmbeddedLocalDraft = (runtimeDescriptor) => {
if (runtimeDescriptor.bootstrap.sourceKind !== 'local_folder') return;
try {
const storage = window.localStorage;
const base = 'mnote.leptos-tiptap-spike.document';
const keys = [
`${base}:${runtimeDescriptor.bootstrap.workspaceId}:${runtimeDescriptor.bootstrap.documentId}`,
`${base}:${runtimeDescriptor.bootstrap.documentId}`,
];
for (const key of keys) storage.removeItem(key);
} catch (error) {
console.warn('mnote local folder 草稿清理失败', error);
}
};
const setSessionStatus = (session, status, message) => {
session.status = status;
session.error = message || null;
sessionViews(session).forEach((view) => {
setStatus(view.runtimeDescriptor, status, message);
});
};
const dispatchSessionContentToView = (session, view, source) => {
view.suppressedSerialized = session.currentSerialized;
view.lastKnownSerialized = session.currentSerialized;
preserveViewScrollPosition(view, () => {
dispatchRuntimeCommand(view, {
command: 'replaceContent',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-session-sync');
});
};
const dispatchSessionMetaToView = (session, view, source) => {
dispatchRuntimeCommand(view, {
command: 'replaceContent',
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
}, source || 'mnote-web-document-session-meta');
};
const syncSessionMetaToViews = (session) => {
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionMetaToView(session, view);
});
};
const broadcastSessionContent = (session, sourceView) => {
sessionViews(session).forEach((view) => {
if (sourceView && view.id === sourceView.id) return;
if (view.mountId == null) return;
dispatchSessionContentToView(session, view);
});
};
const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const sessionPlainText = (session) => {
const liveText = sessionViews(session)
.map((view) => normalizePlainText(currentEditorText(view)))
.find((text) => text);
if (liveText) return liveText;
return normalizePlainText(flattenText(session.currentTiptapDocument));
};
const sessionHasRecentExternalSignal = (session) => (
session.sourceKind === 'local_folder'
&& Number.isFinite(session.lastExternalChangeSignalAt)
&& session.lastExternalChangeSignalAt > 0
&& (Date.now() - session.lastExternalChangeSignalAt) < 1500
);
const sessionHasRecentLocalInput = (session) => (
Number.isFinite(session.lastUserInputAt)
&& session.lastUserInputAt > 0
&& (Date.now() - session.lastUserInputAt) < 1500
);
const fetchLatestSessionAggregate = async (session) => {
const response = await fetch(pageAggregateUrl({
documentId: session.documentId,
sourceKind: session.sourceKind,
workspaceId: session.workspaceId,
rootUri: session.rootUri,
}).toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status);
const payload = await response.json();
const nextAggregate = payload?.result;
if (!nextAggregate || typeof nextAggregate !== 'object') {
throw new Error('conflict_latest_missing_aggregate');
}
return nextAggregate;
};
const fetchLatestResourceSnapshot = async (session) => {
const url = new URL('/api/local-folder/resource/read', window.location.origin);
url.searchParams.set('rootUri', session.rootUri || '');
url.searchParams.set('path', session.resourcePath || '');
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status);
const nextResource = payload.result;
if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot');
return nextResource;
};
const aggregatePlainText = (aggregate) => {
const body = aggregate?.body || {};
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
};
const resourceSnapshotPlainText = (snapshot) => (
String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '')
.replace(/\n{3,}/g, '\n\n')
.trim()
);
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
if (!session || session.sourceKind !== 'local_folder') return false;
if (!sessionHasRecentExternalSignal(session)) return false;
if (!sessionPlainText(session)) return false;
return !aggregatePlainText(nextAggregate);
};
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const conflictEnvelopeFromResponse = (payload) => (
payload?.error?.details?.conflict
|| payload?.details?.conflict
|| null
);
const conflictSourceLabel = (session) => {
if (!session) return '';
if (session.lastExternalWriteSource === 'mnote-hermes-tool') {
const runId = String(session.lastExternalWriteRunId || '').trim();
return runId ? `agent run ${runId}` : 'agent run';
}
return '本地文件变更';
};
const conflictMessageFromEnvelope = (session, envelope) => {
const baseMessage = envelope?.message || externalConflictMessage;
const sourceLabel = conflictSourceLabel(session);
return sourceLabel ? `${baseMessage}(来源:${sourceLabel}` : baseMessage;
};
const ensureSessionConflictEnvelope = (session, message) => {
if (!session) return null;
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
const envelope = {
code: 'local_markdown_external_change',
documentId: session.documentId,
rootUri: session.rootUri || null,
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
externalActor: session.lastExternalWriteSource || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
message: message || externalConflictMessage,
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
};
session.lastExternalConflictEnvelope = envelope;
return envelope;
};
const clearSessionConflictSurface = (session) => {
var _rt_ = window.__mnoteDocumentConflictPanelRuntime;
if (_rt_ && typeof _rt_.clearSessionConflictSurface === 'function') {
_rt_.clearSessionConflictSurface(session, { sessionViews: sessionViews });
return;
}
sessionViews(session).forEach((view) => {
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
if (!(host instanceof HTMLElement)) return;
host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove());
});
};
const applyAggregateSnapshotToSession = (session, nextAggregate, source) => {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = nextSerialized;
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.readOnly = Boolean(nextPermissions.readOnly);
session.dirty = false;
session.hasExternalConflict = false;
session.externalChangePending = false;
session.lastUserInputAt = 0;
clearSessionConflictSurface(session);
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved');
});
setSessionStatus(session, 'synced-external-change');
};
const applyResourceSnapshotToSession = (session, nextResource, source) => {
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
const nextSerialized = JSON.stringify(nextTiptapDocument);
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = nextSerialized;
if (nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
}
session.dirty = false;
session.hasExternalConflict = false;
session.externalChangePending = false;
session.lastUserInputAt = 0;
clearSessionConflictSurface(session);
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved');
});
setSessionStatus(session, 'synced-external-change');
};
const openConflictDiffPanel = async (session, panel) => {
const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]');
if (!(diffPanel instanceof HTMLElement)) return;
diffPanel.hidden = false;
diffPanel.replaceChildren();
const loading = document.createElement('div');
loading.className = 'mnote-conflict-diff-status';
loading.textContent = '正在读取磁盘版本...';
diffPanel.appendChild(loading);
try {
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const latestText = session.sessionKind === 'resource'
? resourceSnapshotPlainText(latest)
: aggregatePlainText(latest);
diffPanel.replaceChildren();
var _dpRT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_dpRT_ && typeof _dpRT_.populateSessionConflictDiffPanel === 'function') {
_dpRT_.populateSessionConflictDiffPanel(diffPanel, {
currentText: sessionPlainText(session) || '(当前编辑器为空)',
diskText: latestText || '(磁盘版本为空)',
currentMergeText: sessionPlainText(session) || '',
diskMergeText: latestText || '',
mergeDefaultText: sessionPlainText(session) || latestText || '',
}, {
onUseCurrent: function() {
return sessionPlainText(session) || '';
},
onUseDisk: function() {
return latestText || '';
},
onSaveMerge: function(mergeText) {
writeMergedConflictResult(session, panel, mergeText).catch((error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
});
},
});
} else {
const current = document.createElement('pre');
current.setAttribute('data-testid', 'mnote-conflict-current-text');
current.textContent = sessionPlainText(session) || '(当前编辑器为空)';
const disk = document.createElement('pre');
disk.setAttribute('data-testid', 'mnote-conflict-disk-text');
disk.textContent = latestText || '(磁盘版本为空)';
const currentTitle = document.createElement('h3');
currentTitle.textContent = '当前编辑器版本';
const diskTitle = document.createElement('h3');
diskTitle.textContent = '磁盘版本';
const currentBox = document.createElement('section');
currentBox.append(currentTitle, current);
const diskBox = document.createElement('section');
diskBox.append(diskTitle, disk);
const mergeTitle = document.createElement('h3');
mergeTitle.textContent = '合并结果';
const mergeText = document.createElement('textarea');
mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text');
mergeText.value = sessionPlainText(session) || latestText || '';
const mergeActions = document.createElement('div');
mergeActions.className = 'mnote-conflict-actions';
const useCurrent = document.createElement('button');
useCurrent.type = 'button';
useCurrent.textContent = '使用当前版本';
useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current');
const useDisk = document.createElement('button');
useDisk.type = 'button';
useDisk.textContent = '使用磁盘版本';
useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk');
const saveMerge = document.createElement('button');
saveMerge.type = 'button';
saveMerge.textContent = '写回合并结果';
saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save');
mergeActions.append(useCurrent, useDisk, saveMerge);
const mergeBox = document.createElement('section');
mergeBox.className = 'mnote-conflict-merge-box';
mergeBox.append(mergeTitle, mergeText, mergeActions);
diffPanel.append(currentBox, diskBox, mergeBox);
useCurrent.addEventListener('click', () => {
mergeText.value = sessionPlainText(session) || '';
});
useDisk.addEventListener('click', () => {
mergeText.value = latestText || '';
});
saveMerge.addEventListener('click', () => {
writeMergedConflictResult(session, panel, mergeText.value).catch((error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
});
});
}
} catch (error) {
loading.textContent = error instanceof Error ? error.message : String(error);
diffPanel.replaceChildren(loading);
}
};
const acceptDiskVersion = async (session) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.acceptDiskVersion === 'function') {
return _RT_.acceptDiskVersion(session, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
applyResourceSnapshotToSession: applyResourceSnapshotToSession,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
applyAggregateSnapshotToSession: applyAggregateSnapshotToSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
if (session.sessionKind === 'resource') {
const latestResource = await fetchLatestResourceSnapshot(session);
applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk');
return;
}
const latest = await fetchLatestSessionAggregate(session);
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
};
const keepCurrentEditorVersion = async (session) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.keepCurrentEditorVersion === 'function') {
return _RT_.keepCurrentEditorVersion(session, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
sessionViews: sessionViews,
currentEditorText: currentEditorText,
normalizePlainText: normalizePlainText,
hydrateMindmapAttrsFromDom: hydrateMindmapAttrsFromDom,
textToTiptapDocument: textToTiptapDocument,
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
clearSessionConflictSurface: clearSessionConflictSurface,
persistSession: persistSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
if (hydrateView) {
const liveText = normalizePlainText(currentEditorText(hydrateView));
if (liveText) {
session.currentTiptapDocument = hydrateMindmapAttrsFromDom(
textToTiptapDocument(liveText),
hydrateView.runtimeDescriptor.root,
);
}
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
}
const nextKey = session.sessionKind === 'resource'
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
: conflictDetectionKeyFromBody(latest.body || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
}
session.hasExternalConflict = false;
session.externalChangePending = false;
session.saving = false;
session.dirty = true;
clearSessionConflictSurface(session);
await persistSession(session);
};
const writeMergedConflictResult = async (session, panel, mergedText) => {
var _RT_ = window.__mnoteDocumentConflictPanelRuntime;
if (_RT_ && typeof _RT_.writeMergedConflictResult === 'function') {
return _RT_.writeMergedConflictResult(session, panel, mergedText, {
setSessionStatus: setSessionStatus,
fetchLatestResourceSnapshot: fetchLatestResourceSnapshot,
fetchLatestSessionAggregate: fetchLatestSessionAggregate,
conflictDetectionKeyFromBody: conflictDetectionKeyFromBody,
textToTiptapDocument: textToTiptapDocument,
persistSession: persistSession,
});
}
// inline fallback
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const nextKey = session.sessionKind === 'resource'
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
: conflictDetectionKeyFromBody(latest.body || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
}
session.currentTiptapDocument = textToTiptapDocument(String(mergedText || ''));
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
session.hasExternalConflict = false;
session.externalChangePending = false;
session.saving = false;
session.dirty = true;
if (panel && typeof panel.remove === 'function') panel.remove();
await persistSession(session);
};
const runSessionConflictAction = (action, onError) => {
const runtime = window.__mnoteDocumentConflictPanelRuntime;
if (runtime && typeof runtime.runSessionConflictAction === 'function') {
runtime.runSessionConflictAction(action, { onError });
return;
}
try {
const result = action();
if (result && typeof result.then === 'function') result.catch(onError);
} catch (error) {
onError(error);
}
};
const renderSessionConflictSurface = (session, message) => {
clearSessionConflictSurface(session);
sessionViews(session).forEach((view) => {
const runtime = window.__mnoteDocumentConflictPanelRuntime;
const handleConflictActionError = (error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
};
let panel = null;
if (runtime && typeof runtime.createSessionConflictPanel === 'function') {
panel = runtime.createSessionConflictPanel(session, message, {
externalConflictMessage,
conflictSourceLabel,
onAcceptDisk: () => {
runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError);
},
onKeepCurrent: () => {
runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError);
},
onOpenDiff: (_session, createdPanel) => {
openConflictDiffPanel(session, createdPanel);
},
});
}
if (!(panel instanceof HTMLElement)) {
panel = document.createElement('section');
panel.className = 'mnote-editor-conflict-panel';
panel.setAttribute('data-testid', 'mnote-editor-conflict-panel');
panel.setAttribute('role', 'status');
panel.setAttribute('aria-live', 'polite');
const heading = document.createElement('h2');
heading.textContent = '文件冲突';
const text = document.createElement('p');
text.textContent = message || externalConflictMessage;
const meta = document.createElement('div');
meta.className = 'mnote-conflict-meta';
const fileLabel = session.sessionKind === 'resource'
? `${session.rootUri || ''}/${session.resourcePath || session.documentId}`
: (session.rootUri || session.documentId);
meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
const actions = document.createElement('div');
actions.className = 'mnote-conflict-actions';
const acceptDisk = document.createElement('button');
acceptDisk.type = 'button';
acceptDisk.textContent = '接受磁盘版本';
acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk');
const keepCurrent = document.createElement('button');
keepCurrent.type = 'button';
keepCurrent.textContent = '保留当前编辑器版本';
keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current');
const openDiff = document.createElement('button');
openDiff.type = 'button';
openDiff.textContent = '打开 diff';
openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff');
actions.append(acceptDisk, keepCurrent, openDiff);
const diffPanel = document.createElement('div');
diffPanel.className = 'mnote-conflict-diff-panel';
diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel');
diffPanel.hidden = true;
panel.append(heading, text, meta, actions, diffPanel);
acceptDisk.addEventListener('click', () => {
runSessionConflictAction(() => acceptDiskVersion(session), handleConflictActionError);
});
keepCurrent.addEventListener('click', () => {
runSessionConflictAction(() => keepCurrentEditorVersion(session), handleConflictActionError);
});
openDiff.addEventListener('click', () => {
openConflictDiffPanel(session, panel);
});
}
if (runtime && typeof runtime.mountSessionConflictPanel === 'function') {
if (runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)) {
return;
}
}
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
if (!(host instanceof HTMLElement)) return;
const header = host.querySelector('.document-shell-header');
if (header && header.parentNode) {
header.parentNode.insertBefore(panel, header.nextSibling);
} else {
host.prepend(panel);
}
});
};
const markSessionExternalConflict = (session, message) => {
session.externalChangePending = false;
ensureSessionConflictEnvelope(session, message);
session.hasExternalConflict = true;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
const nextMessage = message || externalConflictMessage;
setSessionStatus(session, 'external-change-conflict', nextMessage);
renderSessionConflictSurface(session, nextMessage);
};
const queueSessionSave = (session) => {
if (session.readOnly || session.hasExternalConflict) return;
if (session.saveTimer) window.clearTimeout(session.saveTimer);
setSessionStatus(session, 'dirty');
session.saveTimer = window.setTimeout(() => {
session.saveTimer = 0;
void persistSession(session);
}, 650);
};
const persistSession = async (session) => {
if (session.readOnly || session.saving || session.hasExternalConflict) return;
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
if (hydrateView) {
hydrateMindmapAttrsFromDom(session.currentTiptapDocument, hydrateView.runtimeDescriptor.root);
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
}
const serialized = session.currentSerialized;
if (!session.dirty && serialized === session.lastPersistedSerialized) {
setSessionStatus(session, 'saved');
return;
}
session.saving = true;
setSessionStatus(session, 'saving');
try {
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument);
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
const savePayload = session.sessionKind === 'resource'
? {
rootUri: session.rootUri,
path: session.resourcePath,
expectedFileVersion: session.conflictDetectionKey,
contentFormat: 'editorBlocks',
editorSource: 'tiptap-resource-tab',
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
}
: {
documentId: session.documentId,
workspaceId: session.workspaceId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
revision: session.revision,
expectedFileVersion: session.conflictDetectionKey,
contentFormat: 'editorBlocks',
editorSource: 'tiptap',
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
};
if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') {
savePayload.conflictDetectionKey = session.conflictDetectionKey;
}
markLocalFolderSelfChangeSuppression(session);
const response = await fetch(saveEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(savePayload),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result || result.ok !== true) {
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
const conflictEnvelope = conflictEnvelopeFromResponse(result);
if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') {
session.saving = false;
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
throw new Error(message);
}
const saved = result.result || {};
if (Number.isInteger(saved.revision)) session.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
session.conflictDetectionKey = saved.conflict_detection_key.trim();
}
if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) {
session.conflictDetectionKey = saved.conflictDetectionKey.trim();
}
if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) {
session.conflictDetectionKey = saved.fileVersion.trim();
}
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
session.hasExternalConflict = false;
session.externalChangePending = false;
session.lastUserInputAt = 0;
syncSessionMetaToViews(session);
session.lastPersistedSerialized = serialized;
session.saving = false;
if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0];
if (primaryView) {
const plainText = sessionPlainText(session);
window.__mnoteRecordPageHistorySnapshot('save', {
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
characterCount: plainText.replace(/\s/g, '').length,
blockCount: editorDocument.blocks.length,
todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length,
todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length,
});
}
}
if (session.currentSerialized === serialized) {
session.dirty = false;
setSessionStatus(session, 'saved');
} else {
session.dirty = true;
setSessionStatus(session, 'dirty');
queueSessionSave(session);
}
syncResourceSessionTabGuards(session);
} catch (error) {
session.saving = false;
setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error));
syncResourceSessionTabGuards(session);
}
};
const scheduleSessionExternalRefresh = (session, source) => {
if (!session || session.views.size === 0) return;
if (session.externalRefreshTimer) return;
session.externalRefreshSource = source || session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshTimer = window.setTimeout(() => {
const refreshSource = session.externalRefreshSource || 'mnote-web-external-change';
session.externalRefreshSource = '';
session.externalRefreshTimer = 0;
void refreshSessionFromExternalChange(session, refreshSource);
}, 120);
};
const refreshSessionFromExternalChange = async (session, source) => {
if (document.hidden) return;
if (!session || session.views.size === 0) return;
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
try {
const response = await fetch(pageAggregateUrl({
documentId: session.documentId,
sourceKind: session.sourceKind,
workspaceId: session.workspaceId,
rootUri: session.rootUri,
}).toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) {
if (session.sourceKind === 'local_folder') {
markSessionExternalConflict(session, externalConflictMessage);
}
return;
}
const payload = await response.json();
const conflictEnvelope = conflictEnvelopeFromResponse(payload);
if (response.status === 409 && conflictEnvelope) {
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
let nextAggregate = payload?.result;
if (shouldRetryTransientEmptyLocalAggregate(session, nextAggregate)) {
await delay(500);
try {
const retryAggregate = await fetchLatestSessionAggregate(session);
if (aggregatePlainText(retryAggregate)) {
nextAggregate = retryAggregate;
} else {
markSessionExternalConflict(session, '检测到外部编辑器正在写入空内容,已暂停自动刷新以保护当前编辑区。');
return;
}
} catch (_retryError) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
}
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
}
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = session.currentSerialized;
session.revision = nextRevision;
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey || '';
session.readOnly = Boolean(nextPermissions.readOnly);
session.dirty = false;
session.hasExternalConflict = false;
session.lastUserInputAt = 0;
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-external-change');
});
setSessionStatus(session, 'synced-external-change');
} catch (error) {
console.warn('mnote 页面外部更新检测失败', error);
}
};
const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder') return;
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
return;
}
const channelKey = localFolderEventChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', session.rootUri);
url.searchParams.set('documentId', session.documentId);
const eventSource = new EventSource(url.toString());
channel = {
key: channelKey,
rootUri: session.rootUri,
documentId: session.documentId,
eventSource,
sessions: new Map(),
};
eventSource.addEventListener('change', (event) => {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
if (!documentId) return;
const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument) return;
if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetsCurrentDocument && targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
markSessionExternalConflict(targetSession, externalConflictMessage);
return;
}
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
};
localFolderEventRegistry.set(channelKey, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
};
const readTreePayloadData = (payload) => (
payload && typeof payload === 'object'
? (payload.data || payload.delta || payload)
: null
);
const readTreePayloadOverview = (payload) => (
payload && typeof payload === 'object' && payload.overview && typeof payload.overview === 'object'
? payload.overview
: null
);
const readTreePayloadCursor = (payload) => {
const raw = String(payload?.cursor || payload?.revision || '').trim();
if (!raw) return { id: '', createdAt: '', raw: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id || parsed.commandId || parsed.command_id || '').trim(),
createdAt: String(parsed.createdAt || parsed.created_at || '').trim(),
raw,
};
}
} catch (_) {}
return { id: raw, createdAt: '', raw };
};
const treeRecordMatchesPayloadCursor = (record, payload) => {
if (!record || typeof record !== 'object') return false;
const cursor = readTreePayloadCursor(payload);
if (!cursor.id && !cursor.createdAt && !cursor.raw) return false;
const ids = [
record.id,
record._id,
record.command_log_id,
record.commandLogId,
record.domain_event_id,
record.domainEventId,
record.command_id,
record.commandId,
].map((value) => String(value || '').trim()).filter(Boolean);
if (cursor.id && ids.includes(cursor.id)) return true;
const createdAt = String(record.created_at || record.createdAt || '').trim();
return Boolean(cursor.createdAt && createdAt && cursor.createdAt === createdAt);
};
const treeRecordTargetsDocument = (record, documentId) => {
if (!record || typeof record !== 'object' || !documentId) return false;
const targetPageId = String(record.target_page_id || record.targetPageId || '').trim();
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
if (targetPageId === documentId || aggregateId === documentId) return true;
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
if (!payload) return false;
const streamDelta = payload.streamDelta || payload.stream_delta || null;
const deltaDocumentId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.documentId || streamDelta.pageId || streamDelta.document_id || streamDelta.page_id || '').trim()
: '';
return deltaDocumentId === documentId;
};
const collectMindmapIdsFromTreeRecord = (record, documentId, out) => {
if (!record || typeof record !== 'object' || !documentId) return;
if (!treeRecordTargetsDocument(record, documentId)) return;
const targetBlockId = String(record.target_block_id || record.targetBlockId || '').trim();
if (targetBlockId) out.add(targetBlockId);
const aggregateType = String(record.aggregate_type || record.aggregateType || '').trim();
const aggregateId = String(record.aggregate_id || record.aggregateId || '').trim();
if (aggregateType === 'block' && aggregateId) out.add(aggregateId);
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
const streamDelta = payload && typeof payload === 'object' ? (payload.streamDelta || payload.stream_delta || null) : null;
const blockId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
: '';
if (blockId) out.add(blockId);
};
const collectMindmapIdsFromTreePayload = (payload, session) => {
const ids = new Set();
if (!payload || typeof payload !== 'object' || !session?.documentId) return [];
const kind = String(payload.kind || '').trim();
const data = readTreePayloadData(payload);
if (kind === 'delta' && data && typeof data === 'object') {
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
if (!documentId || documentId === session.documentId) {
const blockId = String(data.blockId || data.block_id || '').trim();
if (blockId) ids.add(blockId);
const streamDelta = data.streamDelta || data.stream_delta || null;
const streamBlockId = streamDelta && typeof streamDelta === 'object'
? String(streamDelta.blockId || streamDelta.block_id || '').trim()
: '';
if (streamBlockId) ids.add(streamBlockId);
}
}
if (kind === 'resync') {
const overview = readTreePayloadOverview(payload);
if (overview) {
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
commandLogs
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
domainEvents
.filter((record) => treeRecordMatchesPayloadCursor(record, payload))
.forEach((record) => collectMindmapIdsFromTreeRecord(record, session.documentId, ids));
}
}
return Array.from(ids);
};
const refreshMindmapRuntimesFromTreePayload = (payload, session) => {
const registry = window.__MNOTE_LEPTOS_MINDMAP_BRIDGES__ || {};
collectMindmapIdsFromTreePayload(payload, session).forEach((mindmapId) => {
const bridge = registry[mindmapId];
if (bridge && typeof bridge.refreshProjection === 'function') {
void bridge.refreshProjection('mnote-web-tree-live');
}
});
};
const treePayloadTargetsDocument = (payload, session) => {
if (!payload || typeof payload !== 'object' || !session?.documentId) return false;
if (payload.workspaceId && session.workspaceId && String(payload.workspaceId) !== String(session.workspaceId)) return false;
const data = readTreePayloadData(payload);
if (data && typeof data === 'object') {
const documentId = String(data.documentId || data.pageId || data.document_id || data.page_id || '').trim();
if (documentId === session.documentId) return true;
const documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : [];
if (documents.some((item) => String(item?.id || item?.documentId || '').trim() === session.documentId)) return true;
}
const overview = readTreePayloadOverview(payload);
if (!overview) return false;
const commandLogs = Array.isArray(overview.command_logs) ? overview.command_logs : Array.isArray(overview.commandLogs) ? overview.commandLogs : [];
const domainEvents = Array.isArray(overview.domain_events) ? overview.domain_events : Array.isArray(overview.domainEvents) ? overview.domainEvents : [];
return commandLogs.some((record) => treeRecordTargetsDocument(record, session.documentId))
|| domainEvents.some((record) => treeRecordTargetsDocument(record, session.documentId));
};
const handleTreeExternalChange = (event) => {
const payload = event?.detail?.payload || event?.detail || null;
if (!payload) return;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (session.sourceKind === 'local_folder') return;
if (!treePayloadTargetsDocument(payload, session)) return;
refreshMindmapRuntimesFromTreePayload(payload, session);
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, treeExternalConflictMessage);
return;
}
scheduleSessionExternalRefresh(session, 'mnote-web-tree-live');
});
};
const sessionMatchesDocumentWorkspace = (session, documentId, workspaceId) => {
if (!session) return false;
const doc = String(documentId || '').trim();
const workspace = String(workspaceId || '').trim();
if (doc && session.documentId !== doc) return false;
if (workspace && session.workspaceId && session.workspaceId !== workspace) return false;
return true;
};
const refreshDocumentSessionsFromExternalWrite = (detail, source) => {
const documentId = String(detail?.documentId || '').trim();
const workspaceId = String(detail?.workspaceId || '').trim();
let scheduled = 0;
Array.from(documentSessionRegistry.values()).forEach((session) => {
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
session.lastExternalWriteSource = source || session.lastExternalWriteSource || '';
session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim();
session.lastExternalWriteDocumentId = documentId;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage);
return;
}
scheduled += 1;
scheduleSessionExternalRefresh(session, source || 'mnote-web-external-write');
});
return scheduled;
};
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
});
const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {};
const permissions = runtimeDescriptor.aggregate.head?.permissions || {};
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
workspaceId: runtimeDescriptor.bootstrap.workspaceId,
sourceKind,
rootUri: runtimeDescriptor.bootstrap.rootUri,
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
conflictDetectionKey,
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
lastExternalConflictDetectionKey: conflictDetectionKey || '',
readOnly: Boolean(permissions.readOnly),
dirty: false,
saving: false,
hasExternalConflict: false,
externalChangePending: false,
externalRefreshSource: '',
lastExternalChangeSignalAt: 0,
lastSelfSaveSignalAt: 0,
lastUserInputAt: 0,
status: 'booting',
error: null,
saveTimer: 0,
externalRefreshTimer: 0,
releaseTimer: 0,
views: new Map(),
localFolderChannel: null,
};
ensureLocalFolderEventChannel(session);
return session;
};
const getOrCreateDocumentSession = (runtimeDescriptor) => {
const key = buildDocumentSessionKey(runtimeDescriptor.bootstrap);
const existing = documentSessionRegistry.get(key);
if (existing) {
cancelDocumentSessionRelease(existing);
return existing;
}
const session = createDocumentSession(runtimeDescriptor);
documentSessionRegistry.set(key, session);
return session;
};
const updatePaneChrome = (runtimeDescriptor) => {
const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]');
if (!(pane instanceof HTMLElement)) return;
const aggregate = runtimeDescriptor.aggregate || {};
const bootstrap = runtimeDescriptor.bootstrap || {};
const title = aggregate?.head?.title || '无标题';
const documentId = bootstrap.documentId || aggregate?.identity?.documentId || '';
const workspaceId = bootstrap.workspaceId || aggregate?.identity?.workspaceId || '';
pane.setAttribute('data-pane-document-id', documentId);
pane.setAttribute('data-pane-workspace-id', workspaceId);
pane.setAttribute('data-pane-visible', 'true');
pane.removeAttribute('data-mnote-side-target');
pane.hidden = false;
const shell = pane.querySelector('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-document-id', documentId);
shell.setAttribute('data-workspace-id', workspaceId);
const options = aggregate?.layout?.pageOptions || {};
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
}
pane.querySelectorAll('[data-page-title-input="true"]').forEach((node) => {
if (!(node instanceof HTMLTextAreaElement)) return;
node.value = title;
node.setAttribute('data-document-id', documentId);
node.setAttribute('data-workspace-id', workspaceId);
node.setAttribute('data-title-last-saved', title);
node.setAttribute('data-title-save-status', 'saved');
node.style.height = 'auto';
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
});
pane.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
const pageTab = document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${runtimeDescriptor.paneRole}"]`);
if (pageTab instanceof HTMLElement) {
pageTab.setAttribute('data-document-id', documentId);
pageTab.setAttribute('data-workspace-id', workspaceId);
const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
if (runtimeDescriptor.paneRole === 'primary') {
document.body.dataset.documentId = documentId;
document.body.dataset.mnoteShell = 'document';
delete document.body.dataset.mindmapId;
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', {
detail: { documentId, workspaceId, title }
}));
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
}
});
const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&');
document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => {
if (!(row instanceof HTMLElement)) return;
if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`));
} else {
row.setAttribute('data-active', 'true');
}
});
}
runtimeDescriptor.root.removeAttribute('data-mnote-object-editor');
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id');
runtimeDescriptor.root.removeAttribute('data-mnote-side-target-unsupported');
runtimeDescriptor.root.removeAttribute('data-mnote-side-target-asset-id');
};
const fetchPageAggregateForPane = async (descriptor) => {
const response = await fetch(pageAggregateUrlFromDescriptor(descriptor).toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) throw new Error(`page_aggregate_failed_${response.status}`);
const payload = await response.json();
if (!payload?.result) throw new Error('page_aggregate_missing_result');
return payload.result;
};
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
unmountMindmapPane(paneRole);
const runtime = await loadRuntime();
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
const previousView = paneViewRegistry.get(paneRole);
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete(paneRole);
}
const aggregate = options.aggregate || await fetchPageAggregateForPane(descriptor);
const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole);
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap };
updatePaneChrome(runtimeDescriptor);
if (paneRole === 'secondary' && workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'true');
setSecondaryEditorHostVisible(true);
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
applyStoredSecondaryWidth();
}
const session = getOrCreateDocumentSession(runtimeDescriptor);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
const mountOptions = {
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {},
};
setStatus(runtimeDescriptor, 'loading-assets');
clearEmbeddedLocalDraft(runtimeDescriptor);
try {
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
view.mountId = mountId;
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus(runtimeDescriptor, 'mounting-editor');
paneViewRegistry.set(paneRole, view);
return view;
} catch (error) {
unmountEditorViewBinding(view);
throw error;
}
};
const descriptorFromCurrentUrl = (paneRole, documentId, explicit = {}) => {
const url = currentUrl();
if (paneRole === 'secondary') {
return {
documentId,
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace',
rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '',
};
}
return {
documentId,
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace',
rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '',
};
};
const updatePrimaryUrl = (descriptor, urlFromCaller) => {
const url = urlFromCaller instanceof URL
? urlFromCaller
: new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin);
if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind);
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
pushUrlState(url);
};
const setSecondaryEditorHostVisible = (visible) => {
const host = document.querySelector('[data-testid="mnote-secondary-editor-tab-host"]');
if (host instanceof HTMLElement) host.hidden = !visible;
};
const closeSecondaryPane = (url) => {
const view = paneViewRegistry.get('secondary');
if (view) {
unmountEditorViewBinding(view);
paneViewRegistry.delete('secondary');
}
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
if (pane instanceof HTMLElement) {
pane.hidden = true;
pane.setAttribute('data-pane-visible', 'false');
pane.removeAttribute('data-mnote-side-target');
pane.removeAttribute('data-pane-document-id');
pane.removeAttribute('data-pane-workspace-id');
}
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
if (root instanceof HTMLElement) {
root.replaceChildren();
root.removeAttribute('data-mnote-side-target-unsupported');
root.removeAttribute('data-mnote-side-target-asset-id');
}
Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => {
if (normalizePaneRole(entry?.paneRole) !== 'secondary') return;
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
try {
entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
} catch (error) {
console.warn('mnote secondary mindmap resource tab unmount failed', error);
}
}
if (entry.tab instanceof HTMLElement) entry.tab.remove();
if (entry.panel instanceof HTMLElement) entry.panel.remove();
resourceTabRegistry.delete(key);
removeFromResourceTabMru('secondary', key);
});
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'false');
workspace.style.removeProperty('grid-template-columns');
}
setSecondaryEditorHostVisible(false);
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = true;
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
replaceUrlState(url);
};
const parseJsonScriptFromDocument = (doc, id) => {
const node = doc?.getElementById?.(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (error) {
console.warn(`mnote mindmap shell JSON 解析失败: ${id}`, error);
return null;
}
};
const fetchMindmapShellBootstrap = async (url) => {
const response = await fetch(url.toString(), {
cache: 'no-store',
credentials: 'include',
headers: { accept: 'text/html' },
});
if (!response.ok) throw new Error(`mindmap_shell_failed_${response.status}`);
const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
const bootstrap = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__');
if (!bootstrap || typeof bootstrap !== 'object') throw new Error('mindmap_shell_missing_bootstrap');
const contract = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_SHELL__') || {};
const title = String(bootstrap.title || parsed.querySelector('title')?.textContent || '思维导图').trim() || '思维导图';
return { bootstrap, contract, title };
};
const setPrimaryMindmapSelection = (documentId, mindmapId) => {
const cssEscape = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape : (value) => String(value).replace(/["\\]/g, '\\$&');
const escapedDocId = cssEscape(documentId);
const escapedMindmapId = cssEscape(mindmapId);
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
});
document.querySelectorAll(`.tree-row[data-shell-mode="page"][data-node-id="${escapedDocId}"]`).forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-active', 'true');
});
document.querySelectorAll(`.tree-row[data-shell-mode="filetree"][data-asset-id="${escapedMindmapId}"]`).forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'true');
});
};
const updatePrimaryMindmapChrome = ({ documentId, mindmapId, workspaceId, title, root }) => {
const pane = root.closest('[data-document-pane="true"]');
if (pane instanceof HTMLElement) {
pane.setAttribute('data-pane-document-id', `__mindmap_object__:${documentId}:${mindmapId}`);
pane.setAttribute('data-pane-workspace-id', workspaceId || '');
pane.setAttribute('data-pane-visible', 'true');
pane.hidden = false;
}
const shell = root.closest('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-editor-host', 'mindmap_object');
shell.setAttribute('data-document-id', documentId);
shell.setAttribute('data-workspace-id', workspaceId || '');
shell.setAttribute('data-mindmap-id', mindmapId);
}
document.body.dataset.documentId = documentId;
document.body.dataset.mindmapId = mindmapId;
document.body.dataset.mnoteShell = 'mindmap';
document.title = title;
document.querySelectorAll('[data-page-title-input="true"][data-pane-role="primary"]').forEach((node) => {
if (!(node instanceof HTMLTextAreaElement)) return;
node.value = title;
node.setAttribute('data-document-id', documentId);
node.setAttribute('data-workspace-id', workspaceId || '');
node.setAttribute('data-title-last-saved', title);
node.setAttribute('data-title-save-status', 'saved');
node.style.height = 'auto';
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
});
document.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
root.setAttribute('data-mnote-object-editor', 'mindmap');
root.setAttribute('data-mnote-object-identity', `resource:mindmap:${documentId}:${mindmapId}`);
root.setAttribute('data-mnote-mindmap-id', mindmapId);
setPrimaryMindmapSelection(documentId, mindmapId);
};
const replacePrimaryPaneMindmap = async ({ documentId, mindmapId, workspaceId, url }) => {
const targetUrl = url instanceof URL
? url
: new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`);
const observability = document.querySelector('[data-editor-host-observability][data-pane-role="primary"]');
if (!(root instanceof HTMLElement)) throw new Error('primary_pane_root_missing');
const runtime = await loadRuntime();
const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl);
const previousView = paneViewRegistry.get('primary');
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete('primary');
}
unmountMindmapPane('primary');
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-active', 'mindmap_object');
observability.setAttribute('data-editor-host-status', 'mounting');
}
root.replaceChildren();
root.setAttribute('data-runtime-editor-status', 'booting');
updatePrimaryMindmapChrome({ documentId, mindmapId, workspaceId, title, root });
const mountId = runtime.mount(root, bootstrap);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'mindmap_object');
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-status', 'mounted');
}
mindmapPaneViewRegistry.set('primary', { paneRole: 'primary', root, runtime, mountId });
pushUrlState(targetUrl);
return true;
};
const handleSessionChange = (session, view, event) => {
const payload = normalizeEnvelopePayload(event);
if (!payload) return;
const pendingExternalChange = session.sourceKind === 'local_folder' && session.externalChangePending;
const recentExternalChange = sessionHasRecentExternalSignal(session);
const recentLocalInput = sessionHasRecentLocalInput(session);
const tiptapDocument = hydrateMindmapAttrsFromDom(toTiptapDocument(
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
currentEditorText(view),
), view.runtimeDescriptor.root);
const serialized = JSON.stringify(tiptapDocument);
if (view.suppressedSerialized && view.suppressedSerialized === serialized) {
view.suppressedSerialized = null;
view.lastKnownSerialized = serialized;
return;
}
const previousSerialized = session.currentSerialized;
session.currentTiptapDocument = tiptapDocument;
session.currentSerialized = serialized;
view.lastKnownSerialized = serialized;
if (typeof payload?.title === 'string' && payload.title.trim()) {
session.title = payload.title.trim();
}
if (payload?.meta && typeof payload.meta === 'object') {
if (Number.isInteger(payload.meta.revision)) session.revision = payload.meta.revision;
if (typeof payload.meta.conflictDetectionKey === 'string' && payload.meta.conflictDetectionKey.trim()) {
const nextMetaConflictKey = payload.meta.conflictDetectionKey.trim();
if (conflictDetectionKeyBelongsToSession(session, nextMetaConflictKey)) {
session.conflictDetectionKey = nextMetaConflictKey;
}
}
if (typeof payload.meta.readOnly === 'boolean') {
session.readOnly = payload.meta.readOnly;
}
}
session.dirty = session.currentSerialized !== session.lastPersistedSerialized;
syncResourceSessionTabGuards(session);
if (serialized !== previousSerialized) {
broadcastSessionContent(session, view);
}
if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) {
markSessionExternalConflict(session, externalConflictMessage);
return;
}
if (session.hasExternalConflict) {
setSessionStatus(session, 'external-change-conflict', externalConflictMessage);
return;
}
if (session.dirty) {
queueSessionSave(session);
} else {
setSessionStatus(session, 'saved');
}
};
const unmountEditorViewBinding = (view, options = {}) => {
if (!view || view.disposed) return;
view.disposed = true;
const scheduleRelease = options.scheduleRelease !== false;
const root = view.runtimeDescriptor.root;
if (typeof view.disconnectObserver === 'function') {
view.disconnectObserver();
view.disconnectObserver = null;
}
if (view.onReady) root.removeEventListener(READY_EVENT, view.onReady);
if (view.onError) root.removeEventListener(ERROR_EVENT, view.onError);
if (view.onChange) root.removeEventListener(CHANGE_EVENT, view.onChange);
if (view.onSave) root.removeEventListener(SAVE_EVENT, view.onSave);
if (view.onState) root.removeEventListener(STATE_EVENT, view.onState);
if (view.onKeydown) root.removeEventListener('keydown', view.onKeydown, true);
if (view.onInput) root.removeEventListener('input', view.onInput);
if (view.disconnectSlashObserver) {
view.disconnectSlashObserver();
view.disconnectSlashObserver = null;
}
clearSessionConflictSurface(view.session);
if (view.mountId != null && typeof view.runtime?.unmount === 'function') {
try {
view.runtime.unmount(view.mountId);
} catch (error) {
console.warn('mnote editor view unmount failed', error);
}
}
view.mountId = null;
root.removeAttribute('data-runtime-mount-id');
if (view.session.views.get(view.id) === view) {
view.session.views.delete(view.id);
}
if (scheduleRelease) {
scheduleDocumentSessionRelease(view.session);
}
};
const observeEditorViewBinding = (view) => {
if (typeof MutationObserver !== 'function' || !(document.body instanceof HTMLElement)) {
return;
}
const observer = new MutationObserver(() => {
if (!view.disposed && !view.runtimeDescriptor.root.isConnected) {
unmountEditorViewBinding(view);
}
});
observer.observe(document.body, { childList: true, subtree: true });
view.disconnectObserver = () => observer.disconnect();
};
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 = '<div class="mnote-resource-tab-error" data-resource-tab-error="true"><div class="mnote-resource-tab-error-inner"><h1>资源打开失败</h1><p>无法加载此资源,请检查文件路径和访问权限。</p></div></div>';
}
};
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 = '<span class="mnote-main-tab-badge" aria-hidden="true"></span><span class="mnote-main-tab-title"></span><span class="mnote-main-tab-close" role="button" aria-label="关闭标签页">×</span>';
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 = `<main class="document-shell mnote-resource-tab-text-shell" data-editor-host="leptos_tiptap_resource" data-mnote-editor-kind="resource" data-pane-role="${paneRole}"><div class="mnote-resource-tab-editor-root" data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_resource" data-mnote-editor-kind="resource" data-runtime-editor-status="booting" data-pane-role="${paneRole}"></div><div class="sr-only" data-editor-host-observability="rust-web-resource-tab" data-mnote-editor-kind="resource" data-pane-role="${paneRole}"></div></main>`;
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 = '<img class="mnote-resource-tab-image" alt="">';
const img = entry.panel.querySelector('img');
if (img instanceof HTMLImageElement) {
img.src = href;
img.alt = entry.title;
}
return;
}
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
const frame = entry.panel.querySelector('iframe');
if (frame instanceof HTMLIFrameElement) {
frame.title = entry.title;
frame.src = href;
}
};
const refreshExistingOfficeResourceTab = (entry, input) => {
if (!entry || entry.kind !== 'office') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
if (!nextHref) return false;
const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
const currentHref = frame instanceof HTMLIFrameElement
? String(frame.getAttribute('src') || frame.src || '').trim()
: '';
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
return true;
};
const openMindmapResourceTab = async (entry, input) => {
const documentId = String(input.documentId || currentWebShellDocumentId() || '').trim();
const mindmapId = String(input.mindmapId || input.assetId || '').trim();
if (!documentId || !mindmapId) throw new Error('mindmap_resource_identity_missing');
const targetUrl = new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
const runtime = await loadRuntime();
const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl);
entry.title = String(input.title || title || '思维导图').trim() || '思维导图';
const titleNode = entry.tab?.querySelector?.('.mnote-main-tab-title');
if (titleNode) titleNode.textContent = entry.title;
entry.panel.innerHTML = '<main class="document-shell mnote-resource-tab-mindmap-shell" data-editor-host="mindmap_resource_tab"><div class="mnote-resource-tab-mindmap-root" data-testid="mnote-mindmap-editor-root" data-editor-host-kind="mindmap_resource_tab" data-runtime-editor-status="booting" data-pane-role="resource"></div></main>';
entry.panel.setAttribute('data-mnote-object-editor', 'mindmap');
entry.panel.setAttribute('data-mnote-object-identity', entry.objectIdentity);
entry.panel.setAttribute('data-mnote-mindmap-id', mindmapId);
const root = entry.panel.querySelector('[data-testid="mnote-mindmap-editor-root"]');
if (!(root instanceof HTMLElement)) throw new Error('mindmap_resource_root_missing');
root.setAttribute('data-mnote-object-editor', 'mindmap');
root.setAttribute('data-mnote-object-identity', entry.objectIdentity);
root.setAttribute('data-mnote-mindmap-id', mindmapId);
root.setAttribute('data-document-id', documentId);
const mountId = runtime.mount(root, bootstrap);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'mindmap_resource_tab');
root.setAttribute('data-runtime-editor-status', 'mounted');
entry.view = null;
entry.session = null;
entry.mindmapRuntime = { runtime, mountId, root };
};
const openUnsupportedSideTarget = (input = {}) => {
const url = currentUrl();
secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
const previousView = paneViewRegistry.get('secondary');
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete('secondary');
}
unmountMindmapPane('secondary');
const workspace = document.querySelector('.mnote-document-workspace');
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'true');
setSecondaryEditorHostVisible(true);
applyStoredSecondaryWidth();
}
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
if (pane instanceof HTMLElement) {
pane.hidden = false;
pane.setAttribute('data-pane-visible', 'true');
pane.setAttribute('data-mnote-side-target', 'unsupported-resource');
pane.removeAttribute('data-pane-document-id');
pane.removeAttribute('data-pane-workspace-id');
}
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源';
if (root instanceof HTMLElement) {
root.replaceChildren();
document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => {
if (node instanceof HTMLElement) node.remove();
});
root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
root.setAttribute('data-mnote-side-target-unsupported', 'true');
root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
const placeholder = document.createElement('div');
placeholder.className = 'mnote-resource-tab-error';
placeholder.setAttribute('data-mnote-side-target-placeholder', 'true');
placeholder.innerHTML = '<div class="mnote-resource-tab-error-inner"><h1>暂不支持在侧栏打开此资源</h1><p></p></div>';
const text = placeholder.querySelector('p');
if (text) text.textContent = title;
root.append(placeholder);
}
document.documentElement.setAttribute('data-mnote-side-target-unsupported', 'true');
document.documentElement.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
replaceUrlState(url);
return true;
};
const openResourceInActiveTab = async (input = {}) => {
const paneRole = normalizePaneRole(input.paneRole || input.targetPaneRole || 'primary');
if (paneRole === 'secondary') {
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'true');
applyStoredSecondaryWidth();
}
setSecondaryEditorHostVisible(true);
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
if (pane instanceof HTMLElement) {
pane.hidden = false;
pane.setAttribute('data-pane-visible', 'true');
}
}
bindMainEditorPageTab(paneRole);
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
if (!objectIdentity) return false;
const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
if (paneRole === 'secondary') {
resourceTabRegistry.forEach((entry, key) => {
if (normalizePaneRole(entry.paneRole) !== paneRole) return;
if (key === registryKey) return;
releaseResourceTabEntryRuntime(entry);
if (entry.tab instanceof HTMLElement) entry.tab.remove();
if (entry.panel instanceof HTMLElement) entry.panel.remove();
resourceTabRegistry.delete(key);
removeFromResourceTabMru(paneRole, key);
});
}
const existing = resourceTabRegistry.get(registryKey);
if (existing) {
refreshExistingOfficeResourceTab(existing, input);
activateMainEditorTab(registryKey, paneRole);
return true;
}
const entry = createResourceTabDom({ ...input, objectIdentity, paneRole });
if (!entry) return false;
resourceTabRegistry.set(registryKey, entry);
activateMainEditorTab(registryKey, paneRole);
try {
if (entry.kind === 'mindmap') {
await openMindmapResourceTab(entry, input);
} else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
await openTiptapResourceTab(entry, input);
} else {
openPassiveResourceTab(entry, input);
}
activateMainEditorTab(registryKey, paneRole);
return true;
} catch (error) {
console.warn('mnote resource tab 打开失败', error);
markResourceTabError(entry);
return true;
}
};
const mountPane = async (runtimeDescriptor) => {
const runtime = await loadRuntime();
const session = getOrCreateDocumentSession(runtimeDescriptor);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
const mountOptions = {
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: session.readOnly,
editable: !session.readOnly,
pageOptions: runtimeDescriptor.aggregate.layout?.pageOptions || {},
};
setStatus(runtimeDescriptor, 'loading-assets');
clearEmbeddedLocalDraft(runtimeDescriptor);
try {
const mountId = runtime.mount(runtimeDescriptor.root, mountOptions);
view.mountId = mountId;
runtimeDescriptor.root.setAttribute('data-runtime-mount-id', String(mountId));
runtimeDescriptor.root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
setStatus(runtimeDescriptor, 'mounting-editor');
paneViewRegistry.set(runtimeDescriptor.paneRole, view);
} catch (error) {
unmountEditorViewBinding(view);
throw error;
}
};
window.__mnoteDocumentPaneRuntime = {
openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('primary', descriptor);
activateMainEditorTab('', 'primary');
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
return true;
},
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
const docId = typeof documentId === 'string' ? documentId.trim() : '';
const mapId = typeof mindmapId === 'string' ? mindmapId.trim() : '';
if (!docId || !mapId) return false;
await replacePrimaryPaneMindmap({
documentId: docId,
mindmapId: mapId,
workspaceId: typeof workspaceId === 'string' ? workspaceId.trim() : '',
url,
});
return true;
},
openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('secondary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('secondary', descriptor);
activateMainEditorTab('', 'secondary');
if (url instanceof URL) replaceUrlState(url);
return true;
},
resolveResourceOpen: (input) => resolveResourceOpen(input),
openResourceInActiveTab: openResourceInActiveTab,
activatePageTab: ({ paneRole } = {}) => {
const role = normalizePaneRole(paneRole || 'primary');
bindMainEditorPageTab(role);
activateMainEditorTab('', role);
return true;
},
openResourceAsSideTarget: async (input = {}) => {
return openResourceInActiveTab({ ...input, paneRole: 'secondary', openTarget: 'active-tab' });
},
closeSecondaryDocument: ({ url } = {}) => {
closeSecondaryPane(url instanceof URL ? url : currentUrl());
return true;
},
getOpenEditorsSnapshot: () => buildOpenEditorsSnapshot(),
refreshDocument: async ({ documentId, workspaceId, source } = {}) => {
const targets = Array.from(documentSessionRegistry.values()).filter((session) => (
sessionMatchesDocumentWorkspace(session, documentId, workspaceId)
));
await Promise.all(targets.map((session) => refreshSessionFromExternalChange(
session,
source || 'mnote-web-programmatic-refresh',
)));
return targets.length;
},
refreshPrimaryDocument: async ({ documentId, workspaceId, source } = {}) => {
const primary = paneViewRegistry.get('primary');
if (!primary?.session) return 0;
if (!sessionMatchesDocumentWorkspace(primary.session, documentId, workspaceId)) return 0;
await refreshSessionFromExternalChange(primary.session, source || 'mnote-web-programmatic-refresh');
return 1;
},
};
bindMainEditorPageTab('primary');
bindMainEditorPageTab('secondary');
window.addEventListener('pagehide', () => {
Array.from(documentSessionRegistry.values()).forEach((session) => {
sessionViews(session).forEach((view) => {
unmountEditorViewBinding(view, { scheduleRelease: false });
});
releaseDocumentSession(session);
});
}, { once: true });
if (paneRuntimes.length) {
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
console.error('mnote multi-pane editor mount failed', error);
});
}
})();