- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目 - 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线 - 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径 验证: - cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1 - cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1 - git diff --check - git diff --cached --check - codegraph index . --force && codegraph status . - codegraph sync . && codegraph status .
1283 lines
56 KiB
JavaScript
1283 lines
56 KiB
JavaScript
import {
|
||
localMarkdownDocumentIdFromRelativePath,
|
||
localMarkdownRelativePathFromDocumentId,
|
||
localizeTiptapAssetUrls,
|
||
} from './document-tiptap-conversion-runtime.js';
|
||
|
||
/**
|
||
* @typedef {Object} MNoteWorkspacePath
|
||
* @property {'mnote.workspace_path.v1'} schema
|
||
* @property {string} workspaceId
|
||
* @property {string} sourceKind
|
||
* @property {string} rootUri
|
||
* @property {string} relativePath
|
||
* @property {string} documentId
|
||
* @property {string} objectIdentity
|
||
* @property {string} assetId
|
||
* @property {string} resourceKind
|
||
*
|
||
* @typedef {Object} MNoteOpenEditorEntry
|
||
* @property {string} objectIdentity
|
||
* @property {MNoteWorkspacePath|null} workspacePath
|
||
* @property {'primary'|'secondary'} paneRole
|
||
* @property {string} editorKind
|
||
* @property {boolean} active
|
||
* @property {string} dirtyState
|
||
* @property {boolean} preview
|
||
* @property {boolean} pinned
|
||
* @property {number} lastActiveAt
|
||
*
|
||
* @typedef {Object} MNoteOpenEditorsSnapshot
|
||
* @property {'mnote.open_editors_snapshot.v1'} schema
|
||
* @property {number} generatedAt
|
||
* @property {string} activeObjectIdentity
|
||
* @property {MNoteOpenEditorEntry[]} editors
|
||
* @property {{primary: Object, secondary: Object}} groups
|
||
*/
|
||
|
||
export const createResourceTabRuntime = (dependencies = {}) => {
|
||
const {
|
||
loadRuntime,
|
||
createEditorViewBinding,
|
||
ensureLocalFolderEventChannel,
|
||
unmountEditorViewBinding,
|
||
setStatus,
|
||
documentSessionRegistry,
|
||
sessionHasRecentLocalInput,
|
||
currentUrl,
|
||
replaceUrlState,
|
||
applyStoredSecondaryWidth,
|
||
workspace,
|
||
setSecondaryEditorHostVisible,
|
||
markIntendedSlashRoot,
|
||
toTiptapDocument,
|
||
openMindmapResourceTab,
|
||
unmountMindmapPane,
|
||
paneViewRegistry,
|
||
rootSelector,
|
||
currentDocumentId,
|
||
currentWorkspaceId,
|
||
secondaryQueryParamNames = [],
|
||
} = dependencies;
|
||
const selector = rootSelector || '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
|
||
const resourceTabRegistry = new Map();
|
||
const resourceTabMru = { primary: [], secondary: [] };
|
||
const resourceTabMruMax = 20;
|
||
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
|
||
let onlyofficeBridgeReadyListenerBound = false;
|
||
|
||
const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
|
||
|
||
const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`;
|
||
|
||
const resourceTabHostNodes = (paneRole = 'primary') => {
|
||
const role = normalizePaneRole(paneRole);
|
||
return {
|
||
paneRole: role,
|
||
strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`),
|
||
pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`),
|
||
pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`),
|
||
host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`),
|
||
panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`),
|
||
};
|
||
};
|
||
|
||
const resourceTabBadgeKind = (input, kind) => {
|
||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||
if (kind === 'mindmap') return 'mindmap';
|
||
if (kind === 'office') {
|
||
if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
|
||
if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
|
||
return 'word';
|
||
}
|
||
if (kind === 'pdf') return 'pdf';
|
||
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code';
|
||
if (kind === 'image') return 'image';
|
||
return 'file';
|
||
};
|
||
|
||
const resourceTabWidthType = (entry) => {
|
||
if (!entry) return '';
|
||
const kind = String(entry.kind || '').trim();
|
||
if (kind === 'office') {
|
||
const badgeKind = String(entry.badgeKind || resourceTabBadgeKind(entry, kind) || '').trim();
|
||
if (badgeKind === 'ppt') return 'ppt';
|
||
if (badgeKind === 'sheet') return 'excel';
|
||
return 'word';
|
||
}
|
||
if (kind === 'pdf') return 'pdf';
|
||
if (kind === 'mindmap') return 'mindmap';
|
||
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'markdown';
|
||
return '';
|
||
};
|
||
|
||
const currentWebShellWorkspaceId = () => {
|
||
const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
|
||
if (explicit) return explicit;
|
||
const fromBody = document.body?.dataset?.workspaceId || '';
|
||
if (fromBody) return String(fromBody).trim();
|
||
const shell = document.querySelector('.document-shell[data-workspace-id], [data-document-pane="true"][data-pane-role="primary"][data-pane-workspace-id]');
|
||
if (shell instanceof HTMLElement) {
|
||
const value = shell.getAttribute('data-workspace-id') || shell.getAttribute('data-pane-workspace-id') || '';
|
||
if (value) return value.trim();
|
||
}
|
||
try {
|
||
return currentUrl().searchParams.get('workspaceId') || '';
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const currentFileTreeWorkspacePath = () => {
|
||
try {
|
||
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path]');
|
||
const reader = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow;
|
||
if (row instanceof HTMLElement && typeof reader === 'function') {
|
||
const workspacePath = reader(row);
|
||
if (workspacePath && typeof workspacePath === 'object') {
|
||
return {
|
||
...workspacePath,
|
||
sourceKind: String(workspacePath.sourceKind || row.getAttribute('data-source-kind') || '').trim(),
|
||
rootUri: String(workspacePath.rootUri || row.getAttribute('data-root-uri') || '').trim(),
|
||
};
|
||
}
|
||
}
|
||
if (row instanceof HTMLElement) {
|
||
return {
|
||
sourceKind: String(row.getAttribute('data-source-kind') || '').trim(),
|
||
rootUri: String(row.getAttribute('data-root-uri') || '').trim(),
|
||
};
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
};
|
||
|
||
const currentWebShellDocumentId = () => {
|
||
const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : '';
|
||
if (explicit) return explicit;
|
||
const fromBody = document.body?.dataset?.documentId || '';
|
||
if (fromBody) return String(fromBody).trim();
|
||
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
|
||
if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
|
||
return '';
|
||
};
|
||
|
||
const documentIdForPane = (paneRole) => {
|
||
const role = normalizePaneRole(paneRole);
|
||
const nodes = resourceTabHostNodes(role);
|
||
const fromTab = String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim();
|
||
if (fromTab) return fromTab;
|
||
const pane = document.querySelector(`[data-document-pane="true"][data-pane-role="${role}"]`);
|
||
if (pane instanceof HTMLElement) {
|
||
const fromPane = String(pane.getAttribute('data-pane-document-id') || '').trim();
|
||
if (fromPane) return fromPane;
|
||
const root = pane.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-document-id], .document-shell[data-document-id]');
|
||
if (root instanceof HTMLElement) {
|
||
const fromRoot = String(root.getAttribute('data-document-id') || '').trim();
|
||
if (fromRoot) return fromRoot;
|
||
}
|
||
}
|
||
return role === 'primary' ? currentWebShellDocumentId() : '';
|
||
};
|
||
|
||
const currentWebShellSourceKind = () => {
|
||
try {
|
||
return currentUrl().searchParams.get('sourceKind')
|
||
|| document.body?.dataset?.mnoteSourceKind
|
||
|| currentFileTreeWorkspacePath()?.sourceKind
|
||
|| '';
|
||
} catch (_) {
|
||
return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || '';
|
||
}
|
||
};
|
||
|
||
const currentWebShellRootUri = () => {
|
||
try {
|
||
return currentUrl().searchParams.get('rootUri')
|
||
|| document.body?.dataset?.mnoteRootUri
|
||
|| currentFileTreeWorkspacePath()?.rootUri
|
||
|| '';
|
||
} catch (_) {
|
||
return document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || '';
|
||
}
|
||
};
|
||
|
||
const buildWorkspacePath = ({
|
||
workspacePath,
|
||
workspaceId,
|
||
sourceKind,
|
||
rootUri,
|
||
relativePath,
|
||
documentId,
|
||
objectIdentity,
|
||
assetId,
|
||
resourceKind,
|
||
} = {}) => {
|
||
const fromInput = workspacePath && typeof workspacePath === 'object' ? workspacePath : null;
|
||
if (fromInput && String(fromInput.schema || '') === 'mnote.workspace_path.v1') {
|
||
return {
|
||
schema: 'mnote.workspace_path.v1',
|
||
workspaceId: String(fromInput.workspaceId || workspaceId || '').trim(),
|
||
sourceKind: String(fromInput.sourceKind || sourceKind || '').trim(),
|
||
rootUri: String(fromInput.rootUri || rootUri || '').trim(),
|
||
relativePath: String(fromInput.relativePath || fromInput.localRelativePath || relativePath || '').trim(),
|
||
documentId: String(fromInput.documentId || documentId || '').trim(),
|
||
objectIdentity: fromInput.objectIdentity ?? String(objectIdentity || '').trim(),
|
||
assetId: String(fromInput.assetId || assetId || '').trim(),
|
||
resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(),
|
||
};
|
||
}
|
||
const structuredObjectIdentity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : null;
|
||
const objectIdentityText = structuredObjectIdentity ? '' : String(objectIdentity || '').trim();
|
||
const id = String(objectIdentityText || documentId || assetId || relativePath || '').trim();
|
||
if (!id) return null;
|
||
return {
|
||
schema: 'mnote.workspace_path.v1',
|
||
workspaceId: String(workspaceId || '').trim(),
|
||
sourceKind: String(sourceKind || '').trim(),
|
||
rootUri: String(rootUri || '').trim(),
|
||
relativePath: String(relativePath || '').trim(),
|
||
documentId: String(documentId || '').trim(),
|
||
objectIdentity: structuredObjectIdentity || objectIdentityText,
|
||
assetId: String(assetId || '').trim(),
|
||
resourceKind: String(resourceKind || '').trim(),
|
||
};
|
||
};
|
||
|
||
const normalizeResourceTabKind = (input) => {
|
||
const kind = String(input?.kind || '').trim().toLowerCase();
|
||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||
if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
|
||
if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
|
||
if (/\.pdf$/i.test(title)) return 'pdf';
|
||
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
|
||
if (/\.(md|markdown)$/i.test(title)) return 'markdown';
|
||
if (/\.(txt|log)$/i.test(title)) return 'text';
|
||
if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code';
|
||
return 'file';
|
||
};
|
||
|
||
const resolveResourceOpen = (input = {}) => {
|
||
const kind = normalizeResourceTabKind(input);
|
||
const badgeKind = resourceTabBadgeKind(input, kind);
|
||
const rawTarget = String(input?.openTarget || '').trim().toLowerCase();
|
||
const openTarget = rawTarget === 'new-window' || rawTarget === 'side' ? rawTarget : 'active-tab';
|
||
const editable = kind === 'markdown' || kind === 'text' || 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) return;
|
||
const timestamp = Date.now();
|
||
entry.lastActiveAt = timestamp;
|
||
if (entry.session) entry.session.lastActiveAt = timestamp;
|
||
};
|
||
|
||
const removeFromResourceTabMru = (paneRole, key) => {
|
||
const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
|
||
const index = list.indexOf(key);
|
||
if (index >= 0) list.splice(index, 1);
|
||
};
|
||
|
||
const lastActiveResourceTabKey = (paneRole = 'primary') => {
|
||
const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
|
||
for (const key of list) {
|
||
if (resourceTabRegistry.has(key)) return key;
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const resourceTabCloseGuardReason = (session) => {
|
||
if (!session) return '';
|
||
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
||
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') {
|
||
return bufferDirtyState;
|
||
}
|
||
const hasUnsavedChanges = session.dirty
|
||
|| Boolean(session.saveTimer)
|
||
|| sessionHasRecentLocalInput(session)
|
||
|| (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized);
|
||
if (hasUnsavedChanges) return 'dirty';
|
||
if (session.saving) return 'saving';
|
||
if (session.hasExternalConflict) return 'hasExternalConflict';
|
||
return '';
|
||
};
|
||
|
||
const documentSessionForPane = (paneRole, documentId) => {
|
||
const id = String(documentId || '').trim();
|
||
if (!id || !documentSessionRegistry || typeof documentSessionRegistry.values !== 'function') return null;
|
||
const role = normalizePaneRole(paneRole);
|
||
return Array.from(documentSessionRegistry.values()).find((session) => (
|
||
session
|
||
&& session.sessionKind !== 'resource'
|
||
&& String(session.documentId || '').trim() === id
|
||
&& Array.from(session.views?.values?.() || []).some((view) => normalizePaneRole(view?.runtimeDescriptor?.paneRole) === role)
|
||
)) || Array.from(documentSessionRegistry.values()).find((session) => (
|
||
session
|
||
&& session.sessionKind !== 'resource'
|
||
&& String(session.documentId || '').trim() === id
|
||
)) || null;
|
||
};
|
||
|
||
const documentSessionDirtyState = (session) => {
|
||
if (!session) return '';
|
||
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
||
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') return bufferDirtyState;
|
||
if (session.hasExternalConflict) return 'ExternalModified';
|
||
if (session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session)) return 'Dirty';
|
||
if (session.saving) return 'Saving';
|
||
return '';
|
||
};
|
||
|
||
const resourceTabBufferStateUrl = (entry) => {
|
||
const session = entry?.session;
|
||
if (!session || session.sourceKind !== 'local_folder') return null;
|
||
const relativePath = String(session.resourcePath || entry?.workspacePath?.relativePath || '').trim();
|
||
const rootUri = String(session.rootUri || entry?.workspacePath?.rootUri || '').trim();
|
||
if (!relativePath || !rootUri) return null;
|
||
const url = new URL('/api/documents/buffer-state', window.location.origin);
|
||
url.searchParams.set('documentId', String(session.documentId || entry.objectIdentity || ''));
|
||
url.searchParams.set('sourceKind', 'local_folder');
|
||
url.searchParams.set('rootUri', rootUri);
|
||
url.searchParams.set('relativePath', relativePath);
|
||
const workspaceId = String(session.workspaceId || entry?.workspacePath?.workspaceId || '').trim();
|
||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||
return url;
|
||
};
|
||
|
||
const applyResourceTabCloseGuard = (entry) => {
|
||
if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
|
||
const reason = resourceTabCloseGuardReason(entry.session);
|
||
if (reason) {
|
||
entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason);
|
||
entry.tab.classList.add('is-close-guarded');
|
||
} else {
|
||
entry.tab.removeAttribute(resourceTabCloseGuardAttribute);
|
||
entry.tab.classList.remove('is-close-guarded');
|
||
}
|
||
};
|
||
|
||
const refreshResourceTabBufferState = async (entry) => {
|
||
const url = resourceTabBufferStateUrl(entry);
|
||
if (!url || !entry?.session) return null;
|
||
try {
|
||
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||
const payload = await response.json().catch(() => null);
|
||
if (!response.ok || !payload || payload.ok !== true || !payload.result) return null;
|
||
const dirtyState = String(payload.result.dirtyState || '').trim();
|
||
if (dirtyState) entry.session.bufferDirtyState = dirtyState;
|
||
if (typeof payload.result.fileVersion === 'string' && payload.result.fileVersion.trim()) {
|
||
entry.session.fileVersion = payload.result.fileVersion.trim();
|
||
}
|
||
applyResourceTabCloseGuard(entry);
|
||
return payload.result;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const syncResourceTabCloseGuard = (entry) => {
|
||
applyResourceTabCloseGuard(entry);
|
||
void refreshResourceTabBufferState(entry);
|
||
};
|
||
|
||
const syncResourceSessionTabGuards = (session) => {
|
||
if (!session || session.sessionKind !== 'resource') return;
|
||
resourceTabRegistry.forEach((entry) => {
|
||
if (entry.session === session) syncResourceTabCloseGuard(entry);
|
||
});
|
||
};
|
||
|
||
const officeBridgeDebugForEntry = (entry) => {
|
||
if (!entry?.panel || !(entry.panel instanceof HTMLElement)) return null;
|
||
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||
if (!(frame instanceof HTMLIFrameElement)) return null;
|
||
try {
|
||
const debug = frame.contentWindow?.__MNOTE_ONLYOFFICE_DEBUG__;
|
||
return debug && typeof debug === 'object' ? debug : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
||
const active = entry?.tab instanceof HTMLElement
|
||
? entry.tab.getAttribute('aria-selected') === 'true'
|
||
: false;
|
||
const documentId = String(entry?.documentId || entry?.ownerDocumentId || entry?.session?.ownerDocumentId || '').trim();
|
||
const workspaceId = String(entry?.workspaceId || entry?.session?.workspaceId || currentWebShellWorkspaceId() || '').trim();
|
||
const sourceKind = String(entry?.sourceKind || entry?.session?.sourceKind || currentWebShellSourceKind() || '').trim();
|
||
const rootUri = String(entry?.rootUri || entry?.session?.rootUri || currentWebShellRootUri() || '').trim();
|
||
const relativePath = String(entry?.path || entry?.session?.resourcePath || '').trim();
|
||
const objectIdentity = String(entry?.objectIdentity || key || '').trim();
|
||
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
|
||
const kind = normalizeResourceTabKind(entry);
|
||
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
||
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
|
||
const onlyofficeSessionId = String(
|
||
officeBridgeDebug?.bridgeSessionId
|
||
|| entry?.onlyofficeSessionId
|
||
|| entry?.bridgeSessionId
|
||
|| '',
|
||
).trim();
|
||
return {
|
||
objectIdentity,
|
||
workspacePath: buildWorkspacePath({
|
||
workspaceId,
|
||
sourceKind,
|
||
rootUri,
|
||
relativePath,
|
||
documentId,
|
||
objectIdentity,
|
||
assetId,
|
||
resourceKind: kind,
|
||
workspacePath: entry?.workspacePath,
|
||
}),
|
||
paneRole: normalizePaneRole(entry?.paneRole),
|
||
documentId,
|
||
workspaceId,
|
||
title: String(entry?.title || '资源').trim() || '资源',
|
||
kind,
|
||
editorKind: kind,
|
||
badgeKind: entry?.tab instanceof HTMLElement
|
||
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
|
||
: resourceTabBadgeKind(entry, entry?.kind),
|
||
active,
|
||
dirtyState,
|
||
dirtyGuard: dirtyState,
|
||
assetId,
|
||
path: relativePath,
|
||
onlyofficeSessionId,
|
||
bridgeSessionId: onlyofficeSessionId,
|
||
bridgeSessionReady: Boolean(onlyofficeSessionId),
|
||
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
|
||
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
|
||
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
||
preview: false,
|
||
pinned: false,
|
||
};
|
||
};
|
||
|
||
const buildOpenEditorsSnapshot = () => {
|
||
const generatedAt = Date.now();
|
||
const pageEntries = ['primary', 'secondary'].map((paneRole) => {
|
||
const nodes = resourceTabHostNodes(paneRole);
|
||
const documentId = documentIdForPane(paneRole);
|
||
const workspaceId = String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim();
|
||
const sourceKind = currentWebShellSourceKind();
|
||
const rootUri = currentWebShellRootUri();
|
||
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
|
||
const objectIdentity = `page:${paneRole}`;
|
||
const workspaceObjectIdentity = {
|
||
objectKind: 'page',
|
||
documentId,
|
||
blockId: null,
|
||
assetId: null,
|
||
};
|
||
const active = nodes.pageTab instanceof HTMLElement
|
||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||
: false;
|
||
const pageSession = documentSessionForPane(paneRole, documentId);
|
||
const dirtyState = documentSessionDirtyState(pageSession);
|
||
const pageTitle = nodes.pageTab instanceof HTMLElement
|
||
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
|
||
: '页面';
|
||
return {
|
||
objectIdentity,
|
||
workspacePath: buildWorkspacePath({
|
||
workspaceId,
|
||
sourceKind,
|
||
rootUri,
|
||
relativePath,
|
||
documentId,
|
||
objectIdentity: workspaceObjectIdentity,
|
||
assetId: '',
|
||
resourceKind: 'page',
|
||
}),
|
||
paneRole,
|
||
documentId,
|
||
workspaceId,
|
||
title: pageTitle || '页面',
|
||
kind: 'page',
|
||
editorKind: 'page',
|
||
badgeKind: 'code',
|
||
active,
|
||
dirtyState,
|
||
dirtyGuard: dirtyState,
|
||
lastActiveAt: active ? generatedAt : 0,
|
||
preview: false,
|
||
pinned: true,
|
||
};
|
||
}).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
|
||
const resources = [];
|
||
resourceTabRegistry.forEach((entry, key) => {
|
||
resources.push(openEditorsSnapshotEntry(entry, key, generatedAt));
|
||
});
|
||
const editors = [...pageEntries, ...resources];
|
||
const groupForPane = (paneRole) => {
|
||
const role = normalizePaneRole(paneRole);
|
||
const groupEditors = editors.filter((entry) => normalizePaneRole(entry.paneRole) === role);
|
||
const groupResources = resources.filter((entry) => normalizePaneRole(entry.paneRole) === role);
|
||
const groupActive = groupEditors.find((entry) => entry.active);
|
||
return {
|
||
paneRole: role,
|
||
activeObjectIdentity: groupActive?.objectIdentity || '',
|
||
editors: groupEditors,
|
||
resourceEditors: groupResources,
|
||
};
|
||
};
|
||
const groups = {
|
||
primary: groupForPane('primary'),
|
||
secondary: groupForPane('secondary'),
|
||
};
|
||
const activeObjectIdentity = groups.primary.activeObjectIdentity || groups.secondary.activeObjectIdentity || '';
|
||
return {
|
||
schema: 'mnote.open_editors_snapshot.v1',
|
||
generatedAt,
|
||
activeObjectIdentity,
|
||
editors,
|
||
resourceEditors: resources,
|
||
groups,
|
||
};
|
||
};
|
||
|
||
const syncOpenEditorsSnapshot = () => {
|
||
const snapshot = buildOpenEditorsSnapshot();
|
||
window.__mnoteOpenEditorsSnapshot = snapshot;
|
||
document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length));
|
||
document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || '');
|
||
window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot }));
|
||
return snapshot;
|
||
};
|
||
|
||
const showResourceTabCloseGuardNotice = (entry, reason) => {
|
||
const nodes = resourceTabHostNodes(entry?.paneRole || 'primary');
|
||
const messages = {
|
||
dirty: '当前资源有未保存的修改,保存完成后再关闭。',
|
||
saving: '当前资源正在保存中,请稍后再关闭。',
|
||
hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。',
|
||
};
|
||
const message = messages[reason] || '当前资源暂时无法关闭。';
|
||
let notice = document.getElementById('mnote-resource-close-guard-notice');
|
||
if (!notice) {
|
||
notice = document.createElement('div');
|
||
notice.id = 'mnote-resource-close-guard-notice';
|
||
notice.className = 'mnote-close-guard-notice';
|
||
notice.setAttribute('role', 'status');
|
||
notice.setAttribute('aria-live', 'polite');
|
||
notice.setAttribute('data-mnote-resource-close-guard', '');
|
||
const parent = nodes.strip?.parentNode;
|
||
if (parent instanceof HTMLElement) {
|
||
const panels = parent.querySelector('.mnote-main-tab-panels');
|
||
if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels);
|
||
else parent.append(notice);
|
||
}
|
||
}
|
||
notice.textContent = `${entry?.title || '资源'}:${message}`;
|
||
notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked');
|
||
notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`;
|
||
if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer);
|
||
notice._mnoteHideTimer = window.setTimeout(() => {
|
||
notice.classList.add('is-hiding');
|
||
window.setTimeout(() => {
|
||
if (notice.parentNode) notice.remove();
|
||
}, 260);
|
||
}, 4000);
|
||
};
|
||
|
||
const cssSafe = (value) => {
|
||
const text = String(value || '');
|
||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
|
||
return text.replace(/["\\]/g, '\\$&');
|
||
};
|
||
|
||
const syncActiveResourceFileTreeRow = (activeResource) => {
|
||
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => {
|
||
if (row instanceof HTMLElement) row.setAttribute('data-active', 'false');
|
||
});
|
||
const entry = activeResource ? resourceTabRegistry.get(activeResource) : null;
|
||
if (!entry) return;
|
||
const assetId = String(entry.assetId || entry.session?.assetId || '').trim();
|
||
const path = String(entry.path || entry.session?.resourcePath || '').trim();
|
||
const identity = String(entry.objectIdentity || activeResource || '').trim();
|
||
if (assetId) {
|
||
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`);
|
||
if (row instanceof HTMLElement) {
|
||
row.setAttribute('data-active', 'true');
|
||
return;
|
||
}
|
||
}
|
||
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]');
|
||
for (const row of rows) {
|
||
if (!(row instanceof HTMLElement)) continue;
|
||
const objectIdentity = row.getAttribute('data-object-identity') || '';
|
||
if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) {
|
||
row.setAttribute('data-active', 'true');
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
|
||
const syncActiveResourceWidthType = (activeEntry, paneRole = 'primary') => {
|
||
const role = normalizePaneRole(paneRole);
|
||
const widthType = role === 'primary' ? resourceTabWidthType(activeEntry) : '';
|
||
const targetNodes = [document.documentElement, document.body].filter((node) => node instanceof HTMLElement);
|
||
targetNodes.forEach((node) => {
|
||
if (widthType) node.setAttribute('data-mnote-active-resource-width-type', widthType);
|
||
else node.removeAttribute('data-mnote-active-resource-width-type');
|
||
});
|
||
const shell = document.querySelector(`.document-pane[data-pane-role="${role}"] .document-shell`);
|
||
if (shell instanceof HTMLElement) {
|
||
if (widthType) shell.setAttribute('data-mnote-active-resource-width-type', widthType);
|
||
else shell.removeAttribute('data-mnote-active-resource-width-type');
|
||
}
|
||
window.dispatchEvent(new CustomEvent('mnote:active-resource-tab-changed', {
|
||
detail: {
|
||
paneRole: role,
|
||
active: Boolean(activeEntry),
|
||
widthType,
|
||
objectIdentity: String(activeEntry?.objectIdentity || '')
|
||
}
|
||
}));
|
||
};
|
||
|
||
const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
|
||
const role = normalizePaneRole(paneRole);
|
||
if (role !== 'primary') return;
|
||
const url = currentUrl();
|
||
if (activeResource) {
|
||
const entry = resourceTabRegistry.get(activeResource);
|
||
const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();
|
||
if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
|
||
url.searchParams.set('resourceTab', activeResource);
|
||
} else {
|
||
const documentId = currentWebShellDocumentId();
|
||
if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
|
||
url.searchParams.delete('resourceTab');
|
||
}
|
||
replaceUrlState(url);
|
||
};
|
||
|
||
const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => {
|
||
const activeResource = String(objectIdentity || '').trim();
|
||
const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null;
|
||
const role = normalizePaneRole(activeEntry?.paneRole || paneRole);
|
||
const nodes = resourceTabHostNodes(role);
|
||
if (activeResource) {
|
||
touchResourceTabMru(role, activeResource);
|
||
setResourceTabLastActive(activeResource);
|
||
}
|
||
if (nodes.pageTab instanceof HTMLElement) {
|
||
const activePage = !activeResource;
|
||
nodes.pageTab.classList.toggle('is-active', activePage);
|
||
nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
|
||
nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
|
||
}
|
||
if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
|
||
if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
|
||
resourceTabRegistry.forEach((entry, key) => {
|
||
if (normalizePaneRole(entry.paneRole) !== role) return;
|
||
const active = key === activeResource;
|
||
if (entry.tab instanceof HTMLElement) {
|
||
entry.tab.classList.toggle('is-active', active);
|
||
entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
entry.tab.setAttribute('tabindex', active ? '0' : '-1');
|
||
}
|
||
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
|
||
syncResourceTabCloseGuard(entry);
|
||
});
|
||
syncActiveResourceWidthType(activeEntry, role);
|
||
if (activeEntry) markIntendedSlashRoot(activeEntry);
|
||
if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
|
||
if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
|
||
syncActiveResourceUrlState(activeResource, role);
|
||
syncOpenEditorsSnapshot();
|
||
};
|
||
|
||
const bindMainEditorTabStrip = (paneRole = 'primary') => {
|
||
const nodes = resourceTabHostNodes(paneRole);
|
||
if (!(nodes.strip instanceof HTMLElement)) return;
|
||
if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
|
||
nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
|
||
const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]'))
|
||
.filter((tab) => tab instanceof HTMLElement && tab.isConnected);
|
||
const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
|
||
nodes.strip.addEventListener('keydown', (event) => {
|
||
const tabs = collectTabs();
|
||
if (!tabs.length) return;
|
||
const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement);
|
||
const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs));
|
||
let targetIndex = -1;
|
||
if (event.key === 'ArrowRight') {
|
||
targetIndex = (baseIndex + 1) % tabs.length;
|
||
} else if (event.key === 'ArrowLeft') {
|
||
targetIndex = (baseIndex - 1 + tabs.length) % tabs.length;
|
||
} else if (event.key === 'Home') {
|
||
targetIndex = 0;
|
||
} else if (event.key === 'End') {
|
||
targetIndex = tabs.length - 1;
|
||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
const tab = tabs[baseIndex];
|
||
if (tab instanceof HTMLElement) tab.click();
|
||
return;
|
||
} else {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const target = tabs[targetIndex];
|
||
if (target instanceof HTMLElement) target.focus();
|
||
});
|
||
};
|
||
|
||
const bindMainEditorPageTab = (paneRole = 'primary') => {
|
||
const role = normalizePaneRole(paneRole);
|
||
const nodes = resourceTabHostNodes(role);
|
||
if (!(nodes.pageTab instanceof HTMLElement)) return;
|
||
bindMainEditorTabStrip(role);
|
||
if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return;
|
||
nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true');
|
||
nodes.pageTab.addEventListener('click', (event) => {
|
||
const target = event.target;
|
||
if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return;
|
||
event.preventDefault();
|
||
activateMainEditorTab('', role);
|
||
});
|
||
syncOpenEditorsSnapshot();
|
||
};
|
||
|
||
const closeResourceTab = async (objectIdentity) => {
|
||
const key = String(objectIdentity || '').trim();
|
||
const entry = resourceTabRegistry.get(key);
|
||
if (!entry) return;
|
||
await refreshResourceTabBufferState(entry);
|
||
const guardReason = resourceTabCloseGuardReason(entry.session);
|
||
if (guardReason) {
|
||
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
|
||
syncResourceTabCloseGuard(entry);
|
||
showResourceTabCloseGuardNotice(entry, guardReason);
|
||
return;
|
||
}
|
||
removeFromResourceTabMru(entry.paneRole || 'primary', key);
|
||
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
|
||
if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
|
||
try {
|
||
entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
|
||
} catch (error) {
|
||
console.warn('mnote mindmap resource tab unmount failed', error);
|
||
}
|
||
}
|
||
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,
|
||
badgeKind: resourceTabBadgeKind(input, kind),
|
||
tab,
|
||
panel,
|
||
view: null,
|
||
session: null,
|
||
assetId: String(input.assetId || '').trim(),
|
||
path: String(input.path || '').trim(),
|
||
documentId: String(input.documentId || '').trim(),
|
||
ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(),
|
||
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || '').trim(),
|
||
sourceKind: String(input.sourceKind || currentWebShellSourceKind() || '').trim(),
|
||
rootUri: String(input.rootUri || currentWebShellRootUri() || '').trim(),
|
||
workspacePath: buildWorkspacePath({
|
||
workspacePath: input.workspacePath,
|
||
workspaceId: input.workspaceId || currentWebShellWorkspaceId(),
|
||
sourceKind: input.sourceKind || currentWebShellSourceKind(),
|
||
rootUri: input.rootUri || currentWebShellRootUri(),
|
||
relativePath: input.path,
|
||
documentId: input.documentId,
|
||
objectIdentity,
|
||
assetId: input.assetId,
|
||
resourceKind: kind,
|
||
}),
|
||
lastActiveAt: 0,
|
||
};
|
||
};
|
||
|
||
const releaseResourceTabEntryRuntime = (entry) => {
|
||
if (!entry) return;
|
||
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.resourceWatchEventSource) {
|
||
try {
|
||
entry.resourceWatchEventSource.close();
|
||
} catch (_) {}
|
||
entry.resourceWatchEventSource = 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 localResourceStatUrl = (rootUri, path) => {
|
||
const url = new URL('/api/local-folder/files/stat', window.location.origin);
|
||
url.searchParams.set('rootUri', rootUri || '');
|
||
url.searchParams.set('path', path || '');
|
||
return url.toString();
|
||
};
|
||
|
||
const withResourceReloadToken = (href) => {
|
||
if (!href) return href;
|
||
const url = new URL(href, window.location.origin);
|
||
url.searchParams.set('mnoteResourceReload', String(Date.now()));
|
||
return url.toString();
|
||
};
|
||
|
||
const renderPassiveResourceMissing = (entry, message) => {
|
||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
|
||
entry.panel.innerHTML = '<div class="mnote-resource-tab-error" data-resource-tab-error="true" data-mnote-resource-missing="true"><div class="mnote-resource-tab-error-inner"><h1>资源已不可用</h1><p></p></div></div>';
|
||
const text = entry.panel.querySelector('p');
|
||
if (text instanceof HTMLElement) text.textContent = message || '本地资源文件已被删除或移动。';
|
||
};
|
||
|
||
const reloadPassiveResourceTab = (entry) => {
|
||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||
entry.panel.removeAttribute('data-mnote-resource-missing');
|
||
const img = entry.panel.querySelector('img.mnote-resource-tab-image');
|
||
if (img instanceof HTMLImageElement) {
|
||
img.src = withResourceReloadToken(img.getAttribute('src') || img.src || '');
|
||
return;
|
||
}
|
||
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||
if (frame instanceof HTMLIFrameElement) {
|
||
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
|
||
}
|
||
};
|
||
|
||
const installPassiveResourceWatch = (entry) => {
|
||
if (!entry || entry.session || typeof window.EventSource !== 'function') return;
|
||
const rootUri = String(entry.rootUri || '').trim();
|
||
const path = String(entry.path || '').trim();
|
||
if (!rootUri || !path) return;
|
||
if (entry.resourceWatchEventSource) {
|
||
try {
|
||
entry.resourceWatchEventSource.close();
|
||
} catch (_) {}
|
||
entry.resourceWatchEventSource = null;
|
||
}
|
||
const url = new URL('/api/local-folder/events', window.location.origin);
|
||
url.searchParams.set('rootUri', rootUri);
|
||
url.searchParams.set('resourcePath', path);
|
||
const eventSource = new EventSource(url.toString());
|
||
entry.resourceWatchEventSource = eventSource;
|
||
eventSource.addEventListener('ready', () => {
|
||
if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true');
|
||
});
|
||
eventSource.addEventListener('change', async () => {
|
||
try {
|
||
const response = await fetch(localResourceStatUrl(rootUri, path), {
|
||
cache: 'no-store',
|
||
headers: { accept: 'application/json' },
|
||
});
|
||
const payload = await response.json().catch(() => null);
|
||
const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists);
|
||
if (!exists) {
|
||
renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。');
|
||
return;
|
||
}
|
||
reloadPassiveResourceTab(entry);
|
||
} catch (error) {
|
||
renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error));
|
||
}
|
||
});
|
||
};
|
||
|
||
const 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);
|
||
if (typeof ensureLocalFolderEventChannel === 'function') {
|
||
ensureLocalFolderEventChannel(session);
|
||
}
|
||
return session;
|
||
};
|
||
|
||
const openTiptapResourceTab = async (entry, input) => {
|
||
const runtime = await loadRuntime();
|
||
const paneRole = normalizePaneRole(entry.paneRole);
|
||
entry.panel.innerHTML = `<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 ensureOnlyofficeBridgeReadyListener = () => {
|
||
if (onlyofficeBridgeReadyListenerBound) return;
|
||
onlyofficeBridgeReadyListenerBound = true;
|
||
window.addEventListener('message', (event) => {
|
||
if (event.origin !== window.location.origin) return;
|
||
const detail = event.data && typeof event.data === 'object' ? event.data : null;
|
||
if (!detail || detail.type !== 'mnote:onlyoffice-bridge-ready') return;
|
||
syncOpenEditorsSnapshot();
|
||
});
|
||
};
|
||
|
||
const openPassiveResourceTab = (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;
|
||
}
|
||
installPassiveResourceWatch(entry);
|
||
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;
|
||
if (entry.kind === 'office') {
|
||
ensureOnlyofficeBridgeReadyListener();
|
||
frame.addEventListener('load', () => {
|
||
syncOpenEditorsSnapshot();
|
||
}, { once: true });
|
||
}
|
||
frame.src = href;
|
||
}
|
||
installPassiveResourceWatch(entry);
|
||
};
|
||
|
||
const refreshExistingOfficeResourceTab = (entry, input) => {
|
||
if (!entry || entry.kind !== 'office') return false;
|
||
const nextHref = String(input.officeUrl || input.href || '').trim();
|
||
if (!nextHref) return false;
|
||
const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
|
||
const currentHref = frame instanceof HTMLIFrameElement
|
||
? String(frame.getAttribute('src') || frame.src || '').trim()
|
||
: '';
|
||
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
|
||
return true;
|
||
};
|
||
|
||
const openUnsupportedSideTarget = (input = {}) => {
|
||
const url = currentUrl();
|
||
secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
|
||
const previousView = paneViewRegistry.get('secondary');
|
||
if (previousView) {
|
||
unmountEditorViewBinding(previousView);
|
||
paneViewRegistry.delete('secondary');
|
||
}
|
||
unmountMindmapPane('secondary');
|
||
const workspace = document.querySelector('.mnote-document-workspace');
|
||
if (workspace instanceof HTMLElement) {
|
||
workspace.setAttribute('data-has-secondary-pane', 'true');
|
||
setSecondaryEditorHostVisible(true);
|
||
applyStoredSecondaryWidth();
|
||
}
|
||
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
|
||
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
|
||
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
|
||
if (pane instanceof HTMLElement) {
|
||
pane.hidden = false;
|
||
pane.setAttribute('data-pane-visible', 'true');
|
||
pane.setAttribute('data-mnote-side-target', 'unsupported-resource');
|
||
pane.removeAttribute('data-pane-document-id');
|
||
pane.removeAttribute('data-pane-workspace-id');
|
||
}
|
||
const root = document.querySelector(`${selector}[data-pane-role="secondary"]`);
|
||
const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源';
|
||
if (root instanceof HTMLElement) {
|
||
root.replaceChildren();
|
||
document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => {
|
||
if (node instanceof HTMLElement) node.remove();
|
||
});
|
||
root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
|
||
root.setAttribute('data-mnote-side-target-unsupported', 'true');
|
||
root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'mnote-resource-tab-error';
|
||
placeholder.setAttribute('data-mnote-side-target-placeholder', 'true');
|
||
placeholder.innerHTML = '<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 closeResourceTabsForPane = (paneRole = 'primary') => {
|
||
const role = normalizePaneRole(paneRole);
|
||
Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => {
|
||
if (normalizePaneRole(entry?.paneRole) !== role) return;
|
||
releaseResourceTabEntryRuntime(entry);
|
||
if (entry.tab instanceof HTMLElement) entry.tab.remove();
|
||
if (entry.panel instanceof HTMLElement) entry.panel.remove();
|
||
resourceTabRegistry.delete(key);
|
||
removeFromResourceTabMru(role, key);
|
||
});
|
||
syncOpenEditorsSnapshot();
|
||
};
|
||
|
||
return {
|
||
activateMainEditorTab,
|
||
bindMainEditorPageTab,
|
||
buildOpenEditorsSnapshot,
|
||
closeResourceTabsForPane,
|
||
normalizePaneRole,
|
||
openResourceInActiveTab,
|
||
resolveResourceOpen,
|
||
syncResourceSessionTabGuards,
|
||
};
|
||
};
|