2914 lines
132 KiB
JavaScript
2914 lines
132 KiB
JavaScript
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);
|
||
});
|
||
}
|
||
})();
|