Files
mnote/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 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 .
2026-06-01 09:29:12 +08:00

1283 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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,
};
};