diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md
index 6e1f234e..30d7d693 100644
--- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md
+++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md
@@ -121,7 +121,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tests::mno
- [x] B1. `document-pane-runtime.js`:secondary pane URL、pane resize、pane close、pane runtime registry。
- [x] B2. `document-tiptap-conversion-runtime.js`:legacy block / inline content / marks 转 Tiptap document。
-- [ ] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。
+- [x] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。
- [x] B4. `document-mindmap-host-runtime.js`:primary mindmap object shell、mindmap resource tab mount/unmount。
- [x] B5. `document-slash-position-runtime.js`:slash menu active root、positioning、mutation observer。
- [ ] B6. entrypoint 只负责读取 bootstrap、加载 runtime、装配各子模块。
diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js
index c0929fec..0118aeb8 100644
--- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js
+++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js
@@ -1,4 +1,5 @@
import { createMindmapHostRuntime } from './document-mindmap-host-runtime.js';
+import { createResourceTabRuntime } from './document-resource-tab-runtime.js';
import {
installGlobalSlashMenuPositioning,
markIntendedSlashRoot,
@@ -170,10 +171,22 @@ import {
const documentSessionRegistry = new Map();
const localFolderEventRegistry = new Map();
const paneViewRegistry = new Map();
- const resourceTabRegistry = new Map();
- const resourceTabMru = { primary: [], secondary: [] };
- const resourceTabMruMax = 20;
- const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
+ const currentWebShellWorkspaceId = () => {
+ try {
+ return currentUrl().searchParams.get('workspaceId') || '';
+ } catch (_) {
+ return '';
+ }
+ };
+
+ const currentWebShellDocumentId = () => {
+ const fromBody = document.body?.dataset?.documentId || '';
+ if (fromBody) return String(fromBody).trim();
+ const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
+ if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
+ return '';
+ };
+
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
@@ -188,6 +201,11 @@ import {
currentDocumentId: () => currentWebShellDocumentId(),
});
+ let resourceTabs = null;
+ const syncResourceSessionTabGuards = (session) => {
+ if (resourceTabs) resourceTabs.syncResourceSessionTabGuards(session);
+ };
+
const ensureLocalFolderSelfChangeSuppressions = () => {
const now = Date.now();
const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map
@@ -1694,21 +1712,7 @@ import {
root.removeAttribute('data-mnote-side-target-unsupported');
root.removeAttribute('data-mnote-side-target-asset-id');
}
- Array.from(resourceTabRegistry.entries()).forEach(([key, entry]) => {
- if (normalizePaneRole(entry?.paneRole) !== 'secondary') return;
- if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
- if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
- try {
- entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
- } catch (error) {
- console.warn('mnote secondary mindmap resource tab unmount failed', error);
- }
- }
- if (entry.tab instanceof HTMLElement) entry.tab.remove();
- if (entry.panel instanceof HTMLElement) entry.panel.remove();
- resourceTabRegistry.delete(key);
- removeFromResourceTabMru('secondary', key);
- });
+ if (resourceTabs) resourceTabs.closeResourceTabsForPane('secondary');
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'false');
workspace.style.removeProperty('grid-template-columns');
@@ -1900,742 +1904,34 @@ import {
return view;
};
- const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
-
- const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`;
-
- const resourceTabHostNodes = (paneRole = 'primary') => {
- const role = normalizePaneRole(paneRole);
- return {
- paneRole: role,
- strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`),
- pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`),
- pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`),
- host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`),
- panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`),
- };
- };
-
- const resourceTabBadgeKind = (input, kind) => {
- const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
- if (kind === 'mindmap') return 'mindmap';
- if (kind === 'office') {
- if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
- if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
- return 'word';
- }
- if (kind === 'pdf') return 'pdf';
- if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code';
- if (kind === 'image') return 'image';
- return 'file';
- };
-
- const currentWebShellWorkspaceId = () => {
- try {
- return currentUrl().searchParams.get('workspaceId') || '';
- } catch (_) {
- return '';
- }
- };
-
- const currentWebShellDocumentId = () => {
- const fromBody = document.body?.dataset?.documentId || '';
- if (fromBody) return String(fromBody).trim();
- const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
- if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
- return '';
- };
-
- const normalizeResourceTabKind = (input) => {
- const kind = String(input?.kind || '').trim().toLowerCase();
- const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
- if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
- if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
- if (/\.pdf$/i.test(title)) return 'pdf';
- if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
- if (/\.(md|markdown)$/i.test(title)) return 'markdown';
- if (/\.(txt|log)$/i.test(title)) return 'text';
- if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code';
- return 'file';
- };
-
- const resolveResourceOpen = (input = {}) => {
- const kind = normalizeResourceTabKind(input);
- const badgeKind = resourceTabBadgeKind(input, kind);
- const rawTarget = String(input?.openTarget || '').trim().toLowerCase();
- const openTarget = rawTarget === 'new-window' || rawTarget === 'side' ? rawTarget : 'active-tab';
- const editable = kind === 'markdown' || kind === 'text' || kind === 'code';
- const defaultOpenMode = kind === 'office' && openTarget === 'active-tab' ? 'active-tab-iframe' : openTarget;
- const viewerUrl = kind === 'office'
- ? String(input?.officeUrl || input?.href || '').trim()
- : String(input?.href || '').trim();
- return { editorKind: kind, badgeKind, defaultOpenMode, editable, viewerUrl, openTarget };
- };
-
- const touchResourceTabMru = (paneRole, key) => {
- const role = normalizePaneRole(paneRole);
- const id = String(key || '').trim();
- if (!id) return;
- const list = resourceTabMru[role] || (resourceTabMru[role] = []);
- const index = list.indexOf(id);
- if (index >= 0) list.splice(index, 1);
- list.unshift(id);
- if (list.length > resourceTabMruMax) list.length = resourceTabMruMax;
- };
-
- const setResourceTabLastActive = (key) => {
- const entry = resourceTabRegistry.get(String(key || '').trim());
- if (entry?.session) entry.session.lastActiveAt = Date.now();
- };
-
- const removeFromResourceTabMru = (paneRole, key) => {
- const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
- const index = list.indexOf(key);
- if (index >= 0) list.splice(index, 1);
- };
-
- const lastActiveResourceTabKey = (paneRole = 'primary') => {
- const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
- for (const key of list) {
- if (resourceTabRegistry.has(key)) return key;
- }
- return '';
- };
-
- const resourceTabCloseGuardReason = (session) => {
- if (!session) return '';
- const hasUnsavedChanges = session.dirty
- || Boolean(session.saveTimer)
- || sessionHasRecentLocalInput(session)
- || (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized);
- if (hasUnsavedChanges) return 'dirty';
- if (session.saving) return 'saving';
- if (session.hasExternalConflict) return 'hasExternalConflict';
- return '';
- };
-
- const syncResourceTabCloseGuard = (entry) => {
- if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
- const reason = resourceTabCloseGuardReason(entry.session);
- if (reason) {
- entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason);
- entry.tab.classList.add('is-close-guarded');
- } else {
- entry.tab.removeAttribute(resourceTabCloseGuardAttribute);
- entry.tab.classList.remove('is-close-guarded');
- }
- };
-
- const syncResourceSessionTabGuards = (session) => {
- if (!session || session.sessionKind !== 'resource') return;
- resourceTabRegistry.forEach((entry) => {
- if (entry.session === session) syncResourceTabCloseGuard(entry);
- });
- };
-
- const openEditorsSnapshotEntry = (entry, key) => ({
- objectIdentity: String(entry?.objectIdentity || key || '').trim(),
- title: String(entry?.title || '资源').trim() || '资源',
- kind: normalizeResourceTabKind(entry),
- badgeKind: entry?.tab instanceof HTMLElement
- ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
- : resourceTabBadgeKind(entry, entry?.kind),
- active: entry?.tab instanceof HTMLElement
- ? entry.tab.getAttribute('aria-selected') === 'true'
- : false,
- dirtyGuard: resourceTabCloseGuardReason(entry?.session),
- assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(),
- path: String(entry?.path || entry?.session?.resourcePath || '').trim(),
+ resourceTabs = createResourceTabRuntime({
+ loadRuntime,
+ createEditorViewBinding,
+ unmountEditorViewBinding,
+ setStatus,
+ documentSessionRegistry,
+ sessionHasRecentLocalInput,
+ currentUrl,
+ replaceUrlState,
+ applyStoredSecondaryWidth,
+ workspace,
+ setSecondaryEditorHostVisible,
+ markIntendedSlashRoot,
+ toTiptapDocument,
+ openMindmapResourceTab: (...args) => mindmapHost.openMindmapResourceTab(...args),
+ unmountMindmapPane: (...args) => mindmapHost.unmountMindmapPane(...args),
+ paneViewRegistry,
+ rootSelector: ROOT_SELECTOR,
+ currentDocumentId: () => currentWebShellDocumentId(),
+ currentWorkspaceId: () => currentWebShellWorkspaceId(),
+ secondaryQueryParamNames,
});
-
- const buildOpenEditorsSnapshot = () => {
- const pageEntries = ['primary', 'secondary'].map((paneRole) => {
- const nodes = resourceTabHostNodes(paneRole);
- const pageTitle = nodes.pageTab instanceof HTMLElement
- ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
- : '页面';
- return {
- objectIdentity: `page:${paneRole}`,
- paneRole,
- documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(),
- workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
- title: pageTitle || '页面',
- kind: 'page',
- badgeKind: 'code',
- active: nodes.pageTab instanceof HTMLElement
- ? nodes.pageTab.getAttribute('aria-selected') === 'true'
- : false,
- dirtyGuard: '',
- };
- }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
- const resources = [];
- resourceTabRegistry.forEach((entry, key) => {
- resources.push(openEditorsSnapshotEntry(entry, key));
- });
- const active = [...pageEntries, ...resources].find((entry) => entry.active);
- return {
- schema: 'mnote.open_editors_snapshot.v1',
- generatedAt: Date.now(),
- activeObjectIdentity: active?.objectIdentity || '',
- editors: [...pageEntries, ...resources],
- resourceEditors: resources,
- };
- };
-
- const syncOpenEditorsSnapshot = () => {
- const snapshot = buildOpenEditorsSnapshot();
- window.__mnoteOpenEditorsSnapshot = snapshot;
- document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length));
- document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || '');
- window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot }));
- return snapshot;
- };
-
- const showResourceTabCloseGuardNotice = (entry, reason) => {
- const nodes = resourceTabHostNodes(entry?.paneRole || 'primary');
- const messages = {
- dirty: '当前资源有未保存的修改,保存完成后再关闭。',
- saving: '当前资源正在保存中,请稍后再关闭。',
- hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。',
- };
- const message = messages[reason] || '当前资源暂时无法关闭。';
- let notice = document.getElementById('mnote-resource-close-guard-notice');
- if (!notice) {
- notice = document.createElement('div');
- notice.id = 'mnote-resource-close-guard-notice';
- notice.className = 'mnote-close-guard-notice';
- notice.setAttribute('role', 'status');
- notice.setAttribute('aria-live', 'polite');
- notice.setAttribute('data-mnote-resource-close-guard', '');
- const parent = nodes.strip?.parentNode;
- if (parent instanceof HTMLElement) {
- const panels = parent.querySelector('.mnote-main-tab-panels');
- if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels);
- else parent.append(notice);
- }
- }
- notice.textContent = `${entry?.title || '资源'}:${message}`;
- notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked');
- notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`;
- if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer);
- notice._mnoteHideTimer = window.setTimeout(() => {
- notice.classList.add('is-hiding');
- window.setTimeout(() => {
- if (notice.parentNode) notice.remove();
- }, 260);
- }, 4000);
- };
-
- const cssSafe = (value) => {
- const text = String(value || '');
- if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
- return text.replace(/["\\]/g, '\\$&');
- };
-
- const syncActiveResourceFileTreeRow = (activeResource) => {
- document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => {
- if (row instanceof HTMLElement) row.setAttribute('data-active', 'false');
- });
- const entry = activeResource ? resourceTabRegistry.get(activeResource) : null;
- if (!entry) return;
- const assetId = String(entry.assetId || entry.session?.assetId || '').trim();
- const path = String(entry.path || entry.session?.resourcePath || '').trim();
- const identity = String(entry.objectIdentity || activeResource || '').trim();
- if (assetId) {
- const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`);
- if (row instanceof HTMLElement) {
- row.setAttribute('data-active', 'true');
- return;
- }
- }
- const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]');
- for (const row of rows) {
- if (!(row instanceof HTMLElement)) continue;
- const objectIdentity = row.getAttribute('data-object-identity') || '';
- if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) {
- row.setAttribute('data-active', 'true');
- return;
- }
- }
- };
-
- const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
- const role = normalizePaneRole(paneRole);
- if (role !== 'primary') return;
- const url = currentUrl();
- if (activeResource) {
- const entry = resourceTabRegistry.get(activeResource);
- const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();
- if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
- url.searchParams.set('resourceTab', activeResource);
- } else {
- const documentId = currentWebShellDocumentId();
- if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
- url.searchParams.delete('resourceTab');
- }
- replaceUrlState(url);
- };
-
- const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => {
- const activeResource = String(objectIdentity || '').trim();
- const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null;
- const role = normalizePaneRole(activeEntry?.paneRole || paneRole);
- const nodes = resourceTabHostNodes(role);
- if (activeResource) {
- touchResourceTabMru(role, activeResource);
- setResourceTabLastActive(activeResource);
- }
- if (nodes.pageTab instanceof HTMLElement) {
- const activePage = !activeResource;
- nodes.pageTab.classList.toggle('is-active', activePage);
- nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
- nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
- }
- if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
- if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
- resourceTabRegistry.forEach((entry, key) => {
- if (normalizePaneRole(entry.paneRole) !== role) return;
- const active = key === activeResource;
- if (entry.tab instanceof HTMLElement) {
- entry.tab.classList.toggle('is-active', active);
- entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
- entry.tab.setAttribute('tabindex', active ? '0' : '-1');
- }
- if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
- syncResourceTabCloseGuard(entry);
- });
- if (activeEntry) markIntendedSlashRoot(activeEntry);
- if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
- if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
- syncActiveResourceUrlState(activeResource, role);
- syncOpenEditorsSnapshot();
- };
-
- const bindMainEditorTabStrip = (paneRole = 'primary') => {
- const nodes = resourceTabHostNodes(paneRole);
- if (!(nodes.strip instanceof HTMLElement)) return;
- if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
- nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
- const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]'))
- .filter((tab) => tab instanceof HTMLElement && tab.isConnected);
- const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
- nodes.strip.addEventListener('keydown', (event) => {
- const tabs = collectTabs();
- if (!tabs.length) return;
- const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement);
- const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs));
- let targetIndex = -1;
- if (event.key === 'ArrowRight') {
- targetIndex = (baseIndex + 1) % tabs.length;
- } else if (event.key === 'ArrowLeft') {
- targetIndex = (baseIndex - 1 + tabs.length) % tabs.length;
- } else if (event.key === 'Home') {
- targetIndex = 0;
- } else if (event.key === 'End') {
- targetIndex = tabs.length - 1;
- } else if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- const tab = tabs[baseIndex];
- if (tab instanceof HTMLElement) tab.click();
- return;
- } else {
- return;
- }
- event.preventDefault();
- const target = tabs[targetIndex];
- if (target instanceof HTMLElement) target.focus();
- });
- };
-
- const bindMainEditorPageTab = (paneRole = 'primary') => {
- const role = normalizePaneRole(paneRole);
- const nodes = resourceTabHostNodes(role);
- if (!(nodes.pageTab instanceof HTMLElement)) return;
- bindMainEditorTabStrip(role);
- if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return;
- nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true');
- nodes.pageTab.addEventListener('click', (event) => {
- const target = event.target;
- if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return;
- event.preventDefault();
- activateMainEditorTab('', role);
- });
- syncOpenEditorsSnapshot();
- };
-
- const closeResourceTab = (objectIdentity) => {
- const key = String(objectIdentity || '').trim();
- const entry = resourceTabRegistry.get(key);
- if (!entry) return;
- const guardReason = resourceTabCloseGuardReason(entry.session);
- if (guardReason) {
- console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
- syncResourceTabCloseGuard(entry);
- showResourceTabCloseGuardNotice(entry, guardReason);
- return;
- }
- removeFromResourceTabMru(entry.paneRole || 'primary', key);
- if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
- if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
- try {
- entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
- } catch (error) {
- console.warn('mnote mindmap resource tab unmount failed', error);
- }
- }
- if (entry.tab instanceof HTMLElement) entry.tab.remove();
- if (entry.panel instanceof HTMLElement) entry.panel.remove();
- resourceTabRegistry.delete(key);
- const role = normalizePaneRole(entry.paneRole);
- const nextKey = lastActiveResourceTabKey(role);
- activateMainEditorTab(nextKey, role);
- const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes(role).pageTab;
- if (nextTab instanceof HTMLElement) nextTab.focus();
- };
-
- const markResourceTabError = (entry) => {
- if (!entry) return;
- entry.kind = 'error';
- if (entry.tab instanceof HTMLElement) {
- entry.tab.setAttribute('data-mnote-tab-kind', 'error');
- entry.tab.setAttribute('data-mnote-tab-badge-kind', 'file');
- entry.tab.classList.add('is-error');
- }
- if (entry.panel instanceof HTMLElement) {
- entry.panel.setAttribute('data-resource-kind', 'error');
- entry.panel.innerHTML = '
资源打开失败
无法加载此资源,请检查文件路径和访问权限。
';
- }
- };
-
- const createResourceTabDom = (input) => {
- const paneRole = normalizePaneRole(input.paneRole);
- const nodes = resourceTabHostNodes(paneRole);
- if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null;
- const objectIdentity = String(input.objectIdentity || '').trim();
- const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
- const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源';
- const kind = normalizeResourceTabKind(input);
- const tab = document.createElement('button');
- tab.type = 'button';
- tab.className = 'mnote-main-tab';
- tab.setAttribute('role', 'tab');
- tab.setAttribute('data-mnote-main-tab', registryKey);
- tab.setAttribute('data-mnote-object-identity', objectIdentity);
- tab.setAttribute('data-pane-role', paneRole);
- tab.setAttribute('data-mnote-tab-kind', kind);
- tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind));
- tab.setAttribute('tabindex', '-1');
- tab.innerHTML = '×';
- const titleNode = tab.querySelector('.mnote-main-tab-title');
- if (titleNode) titleNode.textContent = title;
- tab.addEventListener('click', (event) => {
- const target = event.target;
- if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) {
- event.preventDefault();
- event.stopPropagation();
- closeResourceTab(registryKey);
- return;
- }
- activateMainEditorTab(registryKey, paneRole);
- });
- const panel = document.createElement('section');
- panel.className = 'mnote-resource-tab-panel';
- panel.setAttribute('data-mnote-resource-tab-panel', registryKey);
- panel.setAttribute('data-mnote-object-identity', objectIdentity);
- panel.setAttribute('data-pane-role', paneRole);
- panel.setAttribute('data-resource-kind', kind);
- panel.hidden = true;
- nodes.strip.append(tab);
- nodes.panelRoot.append(panel);
- return {
- objectIdentity,
- registryKey,
- paneRole,
- title,
- kind,
- tab,
- panel,
- view: null,
- session: null,
- assetId: String(input.assetId || '').trim(),
- path: String(input.path || '').trim(),
- documentId: String(input.documentId || '').trim(),
- ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(),
- };
- };
-
- const releaseResourceTabEntryRuntime = (entry) => {
- if (!entry) return;
- if (entry.view) {
- unmountEditorViewBinding(entry.view, { releaseSession: true });
- entry.view = null;
- entry.session = null;
- }
- if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
- try {
- entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
- } catch (error) {
- console.warn('mnote resource tab runtime unmount failed', error);
- }
- entry.mindmapRuntime = null;
- }
- if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren();
- };
-
- const localResourceReadUrl = (rootUri, path) => {
- const url = new URL('/api/local-folder/resource/read', window.location.origin);
- url.searchParams.set('rootUri', rootUri || '');
- url.searchParams.set('path', path || '');
- return url.toString();
- };
-
- const createResourceSession = (entry, input, readResult) => {
- const resourcePath = String(input.path || '');
- const tiptapDocument = localizeTiptapAssetUrls(
- toTiptapDocument(readResult?.content, readResult?.text || ''),
- {
- sourceKind: 'local_folder',
- rootUri: String(input.rootUri || ''),
- documentId: localMarkdownDocumentIdFromRelativePath(resourcePath),
- }
- );
- const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim();
- const session = {
- key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`,
- sessionKind: 'resource',
- documentId: entry.objectIdentity,
- ownerDocumentId: String(input.documentId || currentWebShellDocumentId() || '').trim(),
- workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
- sourceKind: 'local_folder',
- rootUri: String(input.rootUri || ''),
- resourcePath,
- saveEndpoint: '/api/local-folder/resource/write',
- pageAggregateScriptId: '',
- latestAggregate: null,
- title: entry.title,
- currentTiptapDocument: tiptapDocument,
- currentSerialized: JSON.stringify(tiptapDocument),
- lastPersistedSerialized: JSON.stringify(tiptapDocument),
- revision: null,
- conflictDetectionKey,
- fileVersion: conflictDetectionKey,
- lastExternalConflictDetectionKey: conflictDetectionKey,
- readOnly: false,
- lastActiveAt: 0,
- dirty: false,
- saving: false,
- hasExternalConflict: false,
- externalChangePending: false,
- externalRefreshSource: '',
- lastExternalChangeSignalAt: 0,
- lastSelfSaveSignalAt: 0,
- lastExternalWriteSource: '',
- lastExternalWriteRunId: '',
- lastUserInputAt: 0,
- saveTimer: 0,
- externalRefreshTimer: 0,
- releaseTimer: 0,
- views: new Map(),
- localFolderChannel: null,
- status: 'ready',
- error: null,
- };
- documentSessionRegistry.set(session.key, session);
- return session;
- };
-
- const openTiptapResourceTab = async (entry, input) => {
- const runtime = await loadRuntime();
- const paneRole = normalizePaneRole(entry.paneRole);
- entry.panel.innerHTML = ``;
- const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
- const observability = entry.panel.querySelector('[data-editor-host-observability]');
- if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing');
- const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } });
- const payload = await response.json().catch(() => null);
- if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`);
- const readResult = payload.result || {};
- const session = createResourceSession(entry, input, readResult);
- const runtimeDescriptor = {
- paneRole,
- root,
- observability,
- aggregate: { layout: { pageOptions: {} } },
- bootstrap: {
- documentId: String(input.documentId || currentWebShellDocumentId() || '').trim(),
- workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
- sourceKind: 'local_folder',
- rootUri: String(input.rootUri || ''),
- saveEndpoint: '/api/local-folder/resource/write',
- },
- };
- const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
- const mountId = runtime.mount(root, {
- documentId: session.documentId,
- workspaceId: session.workspaceId,
- title: session.title,
- content: session.currentTiptapDocument,
- revision: session.revision,
- conflictDetectionKey: session.conflictDetectionKey,
- readOnly: false,
- editable: true,
- pageOptions: {},
- });
- view.mountId = mountId;
- root.setAttribute('data-runtime-mount-id', String(mountId));
- root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource');
- root.setAttribute('data-document-id', session.ownerDocumentId || '');
- root.setAttribute('data-workspace-id', session.workspaceId || '');
- if (entry.panel instanceof HTMLElement) {
- const shell = entry.panel.querySelector('.document-shell');
- if (shell instanceof HTMLElement) {
- shell.setAttribute('data-document-id', session.ownerDocumentId || '');
- shell.setAttribute('data-workspace-id', session.workspaceId || '');
- }
- }
- setStatus(runtimeDescriptor, 'mounting-editor');
- entry.view = view;
- entry.session = session;
- markIntendedSlashRoot(entry);
- };
-
- const openPassiveResourceTab = (entry, input) => {
- const href = String(input.officeUrl || input.href || '').trim();
- if (entry.kind === 'image') {
- entry.panel.innerHTML = '
';
- const img = entry.panel.querySelector('img');
- if (img instanceof HTMLImageElement) {
- img.src = href;
- img.alt = entry.title;
- }
- return;
- }
- entry.panel.innerHTML = '';
- const frame = entry.panel.querySelector('iframe');
- if (frame instanceof HTMLIFrameElement) {
- frame.title = entry.title;
- frame.src = href;
- }
- };
-
- const refreshExistingOfficeResourceTab = (entry, input) => {
- if (!entry || entry.kind !== 'office') return false;
- const nextHref = String(input.officeUrl || input.href || '').trim();
- if (!nextHref) return false;
- const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
- const currentHref = frame instanceof HTMLIFrameElement
- ? String(frame.getAttribute('src') || frame.src || '').trim()
- : '';
- if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
- return true;
- };
-
- const openUnsupportedSideTarget = (input = {}) => {
- const url = currentUrl();
- secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
- const previousView = paneViewRegistry.get('secondary');
- if (previousView) {
- unmountEditorViewBinding(previousView);
- paneViewRegistry.delete('secondary');
- }
- mindmapHost.unmountMindmapPane('secondary');
- const workspace = document.querySelector('.mnote-document-workspace');
- if (workspace instanceof HTMLElement) {
- workspace.setAttribute('data-has-secondary-pane', 'true');
- setSecondaryEditorHostVisible(true);
- applyStoredSecondaryWidth();
- }
- const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
- if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
- const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
- if (pane instanceof HTMLElement) {
- pane.hidden = false;
- pane.setAttribute('data-pane-visible', 'true');
- pane.setAttribute('data-mnote-side-target', 'unsupported-resource');
- pane.removeAttribute('data-pane-document-id');
- pane.removeAttribute('data-pane-workspace-id');
- }
- const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
- const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源';
- if (root instanceof HTMLElement) {
- root.replaceChildren();
- document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => {
- if (node instanceof HTMLElement) node.remove();
- });
- root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
- root.setAttribute('data-mnote-side-target-unsupported', 'true');
- root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
- const placeholder = document.createElement('div');
- placeholder.className = 'mnote-resource-tab-error';
- placeholder.setAttribute('data-mnote-side-target-placeholder', 'true');
- placeholder.innerHTML = '';
- const text = placeholder.querySelector('p');
- if (text) text.textContent = title;
- root.append(placeholder);
- }
- document.documentElement.setAttribute('data-mnote-side-target-unsupported', 'true');
- document.documentElement.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
- replaceUrlState(url);
- return true;
- };
-
- const openResourceInActiveTab = async (input = {}) => {
- const paneRole = normalizePaneRole(input.paneRole || input.targetPaneRole || 'primary');
- if (paneRole === 'secondary') {
- if (workspace instanceof HTMLElement) {
- workspace.setAttribute('data-has-secondary-pane', 'true');
- applyStoredSecondaryWidth();
- }
- setSecondaryEditorHostVisible(true);
- const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
- if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
- const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
- if (pane instanceof HTMLElement) {
- pane.hidden = false;
- pane.setAttribute('data-pane-visible', 'true');
- }
- }
- bindMainEditorPageTab(paneRole);
- const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
- if (!objectIdentity) return false;
- const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
- if (paneRole === 'secondary') {
- resourceTabRegistry.forEach((entry, key) => {
- if (normalizePaneRole(entry.paneRole) !== paneRole) return;
- if (key === registryKey) return;
- releaseResourceTabEntryRuntime(entry);
- if (entry.tab instanceof HTMLElement) entry.tab.remove();
- if (entry.panel instanceof HTMLElement) entry.panel.remove();
- resourceTabRegistry.delete(key);
- removeFromResourceTabMru(paneRole, key);
- });
- }
- const existing = resourceTabRegistry.get(registryKey);
- if (existing) {
- refreshExistingOfficeResourceTab(existing, input);
- activateMainEditorTab(registryKey, paneRole);
- return true;
- }
- const entry = createResourceTabDom({ ...input, objectIdentity, paneRole });
- if (!entry) return false;
- resourceTabRegistry.set(registryKey, entry);
- activateMainEditorTab(registryKey, paneRole);
- try {
- if (entry.kind === 'mindmap') {
- await mindmapHost.openMindmapResourceTab(entry, input);
- } else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
- await openTiptapResourceTab(entry, input);
- } else {
- openPassiveResourceTab(entry, input);
- }
- activateMainEditorTab(registryKey, paneRole);
- return true;
- } catch (error) {
- console.warn('mnote resource tab 打开失败', error);
- markResourceTabError(entry);
- return true;
- }
- };
+ const normalizePaneRole = (...args) => resourceTabs.normalizePaneRole(...args);
+ const resolveResourceOpen = (...args) => resourceTabs.resolveResourceOpen(...args);
+ const openResourceInActiveTab = (...args) => resourceTabs.openResourceInActiveTab(...args);
+ const activateMainEditorTab = (...args) => resourceTabs.activateMainEditorTab(...args);
+ const bindMainEditorPageTab = (...args) => resourceTabs.bindMainEditorPageTab(...args);
+ const buildOpenEditorsSnapshot = (...args) => resourceTabs.buildOpenEditorsSnapshot(...args);
const mountPane = async (runtimeDescriptor) => {
const runtime = await loadRuntime();
diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
new file mode 100644
index 00000000..b4a5b47b
--- /dev/null
+++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
@@ -0,0 +1,800 @@
+import {
+ localMarkdownDocumentIdFromRelativePath,
+ localizeTiptapAssetUrls,
+} from './document-tiptap-conversion-runtime.js';
+
+export const createResourceTabRuntime = (dependencies = {}) => {
+ const {
+ loadRuntime,
+ createEditorViewBinding,
+ 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';
+
+ const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
+
+ const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`;
+
+ const resourceTabHostNodes = (paneRole = 'primary') => {
+ const role = normalizePaneRole(paneRole);
+ return {
+ paneRole: role,
+ strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`),
+ pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`),
+ pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`),
+ host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`),
+ panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`),
+ };
+ };
+
+ const resourceTabBadgeKind = (input, kind) => {
+ const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
+ if (kind === 'mindmap') return 'mindmap';
+ if (kind === 'office') {
+ if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
+ if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
+ return 'word';
+ }
+ if (kind === 'pdf') return 'pdf';
+ if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code';
+ if (kind === 'image') return 'image';
+ return 'file';
+ };
+
+ const currentWebShellWorkspaceId = () => {
+ const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
+ if (explicit) return explicit;
+ try {
+ return currentUrl().searchParams.get('workspaceId') || '';
+ } catch (_) {
+ return '';
+ }
+ };
+
+ 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 normalizeResourceTabKind = (input) => {
+ const kind = String(input?.kind || '').trim().toLowerCase();
+ const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
+ if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
+ if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
+ if (/\.pdf$/i.test(title)) return 'pdf';
+ if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
+ if (/\.(md|markdown)$/i.test(title)) return 'markdown';
+ if (/\.(txt|log)$/i.test(title)) return 'text';
+ if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code';
+ return 'file';
+ };
+
+ const resolveResourceOpen = (input = {}) => {
+ const kind = normalizeResourceTabKind(input);
+ const badgeKind = resourceTabBadgeKind(input, kind);
+ const rawTarget = String(input?.openTarget || '').trim().toLowerCase();
+ const openTarget = rawTarget === 'new-window' || rawTarget === 'side' ? rawTarget : 'active-tab';
+ const editable = kind === 'markdown' || kind === 'text' || kind === 'code';
+ const defaultOpenMode = kind === 'office' && openTarget === 'active-tab' ? 'active-tab-iframe' : openTarget;
+ const viewerUrl = kind === 'office'
+ ? String(input?.officeUrl || input?.href || '').trim()
+ : String(input?.href || '').trim();
+ return { editorKind: kind, badgeKind, defaultOpenMode, editable, viewerUrl, openTarget };
+ };
+
+ const touchResourceTabMru = (paneRole, key) => {
+ const role = normalizePaneRole(paneRole);
+ const id = String(key || '').trim();
+ if (!id) return;
+ const list = resourceTabMru[role] || (resourceTabMru[role] = []);
+ const index = list.indexOf(id);
+ if (index >= 0) list.splice(index, 1);
+ list.unshift(id);
+ if (list.length > resourceTabMruMax) list.length = resourceTabMruMax;
+ };
+
+ const setResourceTabLastActive = (key) => {
+ const entry = resourceTabRegistry.get(String(key || '').trim());
+ if (entry?.session) entry.session.lastActiveAt = Date.now();
+ };
+
+ const removeFromResourceTabMru = (paneRole, key) => {
+ const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
+ const index = list.indexOf(key);
+ if (index >= 0) list.splice(index, 1);
+ };
+
+ const lastActiveResourceTabKey = (paneRole = 'primary') => {
+ const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
+ for (const key of list) {
+ if (resourceTabRegistry.has(key)) return key;
+ }
+ return '';
+ };
+
+ const resourceTabCloseGuardReason = (session) => {
+ if (!session) return '';
+ const hasUnsavedChanges = session.dirty
+ || Boolean(session.saveTimer)
+ || sessionHasRecentLocalInput(session)
+ || (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized);
+ if (hasUnsavedChanges) return 'dirty';
+ if (session.saving) return 'saving';
+ if (session.hasExternalConflict) return 'hasExternalConflict';
+ return '';
+ };
+
+ const syncResourceTabCloseGuard = (entry) => {
+ if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
+ const reason = resourceTabCloseGuardReason(entry.session);
+ if (reason) {
+ entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason);
+ entry.tab.classList.add('is-close-guarded');
+ } else {
+ entry.tab.removeAttribute(resourceTabCloseGuardAttribute);
+ entry.tab.classList.remove('is-close-guarded');
+ }
+ };
+
+ const syncResourceSessionTabGuards = (session) => {
+ if (!session || session.sessionKind !== 'resource') return;
+ resourceTabRegistry.forEach((entry) => {
+ if (entry.session === session) syncResourceTabCloseGuard(entry);
+ });
+ };
+
+ const openEditorsSnapshotEntry = (entry, key) => ({
+ objectIdentity: String(entry?.objectIdentity || key || '').trim(),
+ title: String(entry?.title || '资源').trim() || '资源',
+ kind: normalizeResourceTabKind(entry),
+ badgeKind: entry?.tab instanceof HTMLElement
+ ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
+ : resourceTabBadgeKind(entry, entry?.kind),
+ active: entry?.tab instanceof HTMLElement
+ ? entry.tab.getAttribute('aria-selected') === 'true'
+ : false,
+ dirtyGuard: resourceTabCloseGuardReason(entry?.session),
+ assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(),
+ path: String(entry?.path || entry?.session?.resourcePath || '').trim(),
+ });
+
+ const buildOpenEditorsSnapshot = () => {
+ const pageEntries = ['primary', 'secondary'].map((paneRole) => {
+ const nodes = resourceTabHostNodes(paneRole);
+ const pageTitle = nodes.pageTab instanceof HTMLElement
+ ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
+ : '页面';
+ return {
+ objectIdentity: `page:${paneRole}`,
+ paneRole,
+ documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(),
+ workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
+ title: pageTitle || '页面',
+ kind: 'page',
+ badgeKind: 'code',
+ active: nodes.pageTab instanceof HTMLElement
+ ? nodes.pageTab.getAttribute('aria-selected') === 'true'
+ : false,
+ dirtyGuard: '',
+ };
+ }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
+ const resources = [];
+ resourceTabRegistry.forEach((entry, key) => {
+ resources.push(openEditorsSnapshotEntry(entry, key));
+ });
+ const active = [...pageEntries, ...resources].find((entry) => entry.active);
+ return {
+ schema: 'mnote.open_editors_snapshot.v1',
+ generatedAt: Date.now(),
+ activeObjectIdentity: active?.objectIdentity || '',
+ editors: [...pageEntries, ...resources],
+ resourceEditors: resources,
+ };
+ };
+
+ const syncOpenEditorsSnapshot = () => {
+ const snapshot = buildOpenEditorsSnapshot();
+ window.__mnoteOpenEditorsSnapshot = snapshot;
+ document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length));
+ document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || '');
+ window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot }));
+ return snapshot;
+ };
+
+ const showResourceTabCloseGuardNotice = (entry, reason) => {
+ const nodes = resourceTabHostNodes(entry?.paneRole || 'primary');
+ const messages = {
+ dirty: '当前资源有未保存的修改,保存完成后再关闭。',
+ saving: '当前资源正在保存中,请稍后再关闭。',
+ hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。',
+ };
+ const message = messages[reason] || '当前资源暂时无法关闭。';
+ let notice = document.getElementById('mnote-resource-close-guard-notice');
+ if (!notice) {
+ notice = document.createElement('div');
+ notice.id = 'mnote-resource-close-guard-notice';
+ notice.className = 'mnote-close-guard-notice';
+ notice.setAttribute('role', 'status');
+ notice.setAttribute('aria-live', 'polite');
+ notice.setAttribute('data-mnote-resource-close-guard', '');
+ const parent = nodes.strip?.parentNode;
+ if (parent instanceof HTMLElement) {
+ const panels = parent.querySelector('.mnote-main-tab-panels');
+ if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels);
+ else parent.append(notice);
+ }
+ }
+ notice.textContent = `${entry?.title || '资源'}:${message}`;
+ notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked');
+ notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`;
+ if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer);
+ notice._mnoteHideTimer = window.setTimeout(() => {
+ notice.classList.add('is-hiding');
+ window.setTimeout(() => {
+ if (notice.parentNode) notice.remove();
+ }, 260);
+ }, 4000);
+ };
+
+ const cssSafe = (value) => {
+ const text = String(value || '');
+ if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
+ return text.replace(/["\\]/g, '\\$&');
+ };
+
+ const syncActiveResourceFileTreeRow = (activeResource) => {
+ document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => {
+ if (row instanceof HTMLElement) row.setAttribute('data-active', 'false');
+ });
+ const entry = activeResource ? resourceTabRegistry.get(activeResource) : null;
+ if (!entry) return;
+ const assetId = String(entry.assetId || entry.session?.assetId || '').trim();
+ const path = String(entry.path || entry.session?.resourcePath || '').trim();
+ const identity = String(entry.objectIdentity || activeResource || '').trim();
+ if (assetId) {
+ const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`);
+ if (row instanceof HTMLElement) {
+ row.setAttribute('data-active', 'true');
+ return;
+ }
+ }
+ const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]');
+ for (const row of rows) {
+ if (!(row instanceof HTMLElement)) continue;
+ const objectIdentity = row.getAttribute('data-object-identity') || '';
+ if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) {
+ row.setAttribute('data-active', 'true');
+ return;
+ }
+ }
+ };
+
+ const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
+ const role = normalizePaneRole(paneRole);
+ if (role !== 'primary') return;
+ const url = currentUrl();
+ if (activeResource) {
+ const entry = resourceTabRegistry.get(activeResource);
+ const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();
+ if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
+ url.searchParams.set('resourceTab', activeResource);
+ } else {
+ const documentId = currentWebShellDocumentId();
+ if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
+ url.searchParams.delete('resourceTab');
+ }
+ replaceUrlState(url);
+ };
+
+ const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => {
+ const activeResource = String(objectIdentity || '').trim();
+ const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null;
+ const role = normalizePaneRole(activeEntry?.paneRole || paneRole);
+ const nodes = resourceTabHostNodes(role);
+ if (activeResource) {
+ touchResourceTabMru(role, activeResource);
+ setResourceTabLastActive(activeResource);
+ }
+ if (nodes.pageTab instanceof HTMLElement) {
+ const activePage = !activeResource;
+ nodes.pageTab.classList.toggle('is-active', activePage);
+ nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
+ nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
+ }
+ if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
+ if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
+ resourceTabRegistry.forEach((entry, key) => {
+ if (normalizePaneRole(entry.paneRole) !== role) return;
+ const active = key === activeResource;
+ if (entry.tab instanceof HTMLElement) {
+ entry.tab.classList.toggle('is-active', active);
+ entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
+ entry.tab.setAttribute('tabindex', active ? '0' : '-1');
+ }
+ if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
+ syncResourceTabCloseGuard(entry);
+ });
+ if (activeEntry) markIntendedSlashRoot(activeEntry);
+ if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
+ if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
+ syncActiveResourceUrlState(activeResource, role);
+ syncOpenEditorsSnapshot();
+ };
+
+ const bindMainEditorTabStrip = (paneRole = 'primary') => {
+ const nodes = resourceTabHostNodes(paneRole);
+ if (!(nodes.strip instanceof HTMLElement)) return;
+ if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
+ nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
+ const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]'))
+ .filter((tab) => tab instanceof HTMLElement && tab.isConnected);
+ const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
+ nodes.strip.addEventListener('keydown', (event) => {
+ const tabs = collectTabs();
+ if (!tabs.length) return;
+ const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement);
+ const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs));
+ let targetIndex = -1;
+ if (event.key === 'ArrowRight') {
+ targetIndex = (baseIndex + 1) % tabs.length;
+ } else if (event.key === 'ArrowLeft') {
+ targetIndex = (baseIndex - 1 + tabs.length) % tabs.length;
+ } else if (event.key === 'Home') {
+ targetIndex = 0;
+ } else if (event.key === 'End') {
+ targetIndex = tabs.length - 1;
+ } else if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ const tab = tabs[baseIndex];
+ if (tab instanceof HTMLElement) tab.click();
+ return;
+ } else {
+ return;
+ }
+ event.preventDefault();
+ const target = tabs[targetIndex];
+ if (target instanceof HTMLElement) target.focus();
+ });
+ };
+
+ const bindMainEditorPageTab = (paneRole = 'primary') => {
+ const role = normalizePaneRole(paneRole);
+ const nodes = resourceTabHostNodes(role);
+ if (!(nodes.pageTab instanceof HTMLElement)) return;
+ bindMainEditorTabStrip(role);
+ if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return;
+ nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true');
+ nodes.pageTab.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return;
+ event.preventDefault();
+ activateMainEditorTab('', role);
+ });
+ syncOpenEditorsSnapshot();
+ };
+
+ const closeResourceTab = (objectIdentity) => {
+ const key = String(objectIdentity || '').trim();
+ const entry = resourceTabRegistry.get(key);
+ if (!entry) return;
+ const guardReason = resourceTabCloseGuardReason(entry.session);
+ if (guardReason) {
+ console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
+ syncResourceTabCloseGuard(entry);
+ showResourceTabCloseGuardNotice(entry, guardReason);
+ return;
+ }
+ removeFromResourceTabMru(entry.paneRole || 'primary', key);
+ if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
+ if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
+ try {
+ entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
+ } catch (error) {
+ console.warn('mnote mindmap resource tab unmount failed', error);
+ }
+ }
+ if (entry.tab instanceof HTMLElement) entry.tab.remove();
+ if (entry.panel instanceof HTMLElement) entry.panel.remove();
+ resourceTabRegistry.delete(key);
+ const role = normalizePaneRole(entry.paneRole);
+ const nextKey = lastActiveResourceTabKey(role);
+ activateMainEditorTab(nextKey, role);
+ const nextTab = nextKey ? resourceTabRegistry.get(nextKey)?.tab : resourceTabHostNodes(role).pageTab;
+ if (nextTab instanceof HTMLElement) nextTab.focus();
+ };
+
+ const markResourceTabError = (entry) => {
+ if (!entry) return;
+ entry.kind = 'error';
+ if (entry.tab instanceof HTMLElement) {
+ entry.tab.setAttribute('data-mnote-tab-kind', 'error');
+ entry.tab.setAttribute('data-mnote-tab-badge-kind', 'file');
+ entry.tab.classList.add('is-error');
+ }
+ if (entry.panel instanceof HTMLElement) {
+ entry.panel.setAttribute('data-resource-kind', 'error');
+ entry.panel.innerHTML = '资源打开失败
无法加载此资源,请检查文件路径和访问权限。
';
+ }
+ };
+
+ const createResourceTabDom = (input) => {
+ const paneRole = normalizePaneRole(input.paneRole);
+ const nodes = resourceTabHostNodes(paneRole);
+ if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null;
+ const objectIdentity = String(input.objectIdentity || '').trim();
+ const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
+ const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源';
+ const kind = normalizeResourceTabKind(input);
+ const tab = document.createElement('button');
+ tab.type = 'button';
+ tab.className = 'mnote-main-tab';
+ tab.setAttribute('role', 'tab');
+ tab.setAttribute('data-mnote-main-tab', registryKey);
+ tab.setAttribute('data-mnote-object-identity', objectIdentity);
+ tab.setAttribute('data-pane-role', paneRole);
+ tab.setAttribute('data-mnote-tab-kind', kind);
+ tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind));
+ tab.setAttribute('tabindex', '-1');
+ tab.innerHTML = '×';
+ const titleNode = tab.querySelector('.mnote-main-tab-title');
+ if (titleNode) titleNode.textContent = title;
+ tab.addEventListener('click', (event) => {
+ const target = event.target;
+ if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) {
+ event.preventDefault();
+ event.stopPropagation();
+ closeResourceTab(registryKey);
+ return;
+ }
+ activateMainEditorTab(registryKey, paneRole);
+ });
+ const panel = document.createElement('section');
+ panel.className = 'mnote-resource-tab-panel';
+ panel.setAttribute('data-mnote-resource-tab-panel', registryKey);
+ panel.setAttribute('data-mnote-object-identity', objectIdentity);
+ panel.setAttribute('data-pane-role', paneRole);
+ panel.setAttribute('data-resource-kind', kind);
+ panel.hidden = true;
+ nodes.strip.append(tab);
+ nodes.panelRoot.append(panel);
+ return {
+ objectIdentity,
+ registryKey,
+ paneRole,
+ title,
+ kind,
+ tab,
+ panel,
+ view: null,
+ session: null,
+ assetId: String(input.assetId || '').trim(),
+ path: String(input.path || '').trim(),
+ documentId: String(input.documentId || '').trim(),
+ ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(),
+ };
+ };
+
+ const releaseResourceTabEntryRuntime = (entry) => {
+ if (!entry) return;
+ if (entry.view) {
+ unmountEditorViewBinding(entry.view, { releaseSession: true });
+ entry.view = null;
+ entry.session = null;
+ }
+ if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
+ try {
+ entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
+ } catch (error) {
+ console.warn('mnote resource tab runtime unmount failed', error);
+ }
+ entry.mindmapRuntime = null;
+ }
+ if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren();
+ };
+
+ const localResourceReadUrl = (rootUri, path) => {
+ const url = new URL('/api/local-folder/resource/read', window.location.origin);
+ url.searchParams.set('rootUri', rootUri || '');
+ url.searchParams.set('path', path || '');
+ return url.toString();
+ };
+
+ const createResourceSession = (entry, input, readResult) => {
+ const resourcePath = String(input.path || '');
+ const tiptapDocument = localizeTiptapAssetUrls(
+ toTiptapDocument(readResult?.content, readResult?.text || ''),
+ {
+ sourceKind: 'local_folder',
+ rootUri: String(input.rootUri || ''),
+ documentId: localMarkdownDocumentIdFromRelativePath(resourcePath),
+ }
+ );
+ const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim();
+ const session = {
+ key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`,
+ sessionKind: 'resource',
+ documentId: entry.objectIdentity,
+ ownerDocumentId: String(input.documentId || currentWebShellDocumentId() || '').trim(),
+ workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
+ sourceKind: 'local_folder',
+ rootUri: String(input.rootUri || ''),
+ resourcePath,
+ saveEndpoint: '/api/local-folder/resource/write',
+ pageAggregateScriptId: '',
+ latestAggregate: null,
+ title: entry.title,
+ currentTiptapDocument: tiptapDocument,
+ currentSerialized: JSON.stringify(tiptapDocument),
+ lastPersistedSerialized: JSON.stringify(tiptapDocument),
+ revision: null,
+ conflictDetectionKey,
+ fileVersion: conflictDetectionKey,
+ lastExternalConflictDetectionKey: conflictDetectionKey,
+ readOnly: false,
+ lastActiveAt: 0,
+ dirty: false,
+ saving: false,
+ hasExternalConflict: false,
+ externalChangePending: false,
+ externalRefreshSource: '',
+ lastExternalChangeSignalAt: 0,
+ lastSelfSaveSignalAt: 0,
+ lastExternalWriteSource: '',
+ lastExternalWriteRunId: '',
+ lastUserInputAt: 0,
+ saveTimer: 0,
+ externalRefreshTimer: 0,
+ releaseTimer: 0,
+ views: new Map(),
+ localFolderChannel: null,
+ status: 'ready',
+ error: null,
+ };
+ documentSessionRegistry.set(session.key, session);
+ return session;
+ };
+
+ const openTiptapResourceTab = async (entry, input) => {
+ const runtime = await loadRuntime();
+ const paneRole = normalizePaneRole(entry.paneRole);
+ entry.panel.innerHTML = ``;
+ const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
+ const observability = entry.panel.querySelector('[data-editor-host-observability]');
+ if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing');
+ const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`);
+ const readResult = payload.result || {};
+ const session = createResourceSession(entry, input, readResult);
+ const runtimeDescriptor = {
+ paneRole,
+ root,
+ observability,
+ aggregate: { layout: { pageOptions: {} } },
+ bootstrap: {
+ documentId: String(input.documentId || currentWebShellDocumentId() || '').trim(),
+ workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
+ sourceKind: 'local_folder',
+ rootUri: String(input.rootUri || ''),
+ saveEndpoint: '/api/local-folder/resource/write',
+ },
+ };
+ const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
+ const mountId = runtime.mount(root, {
+ documentId: session.documentId,
+ workspaceId: session.workspaceId,
+ title: session.title,
+ content: session.currentTiptapDocument,
+ revision: session.revision,
+ conflictDetectionKey: session.conflictDetectionKey,
+ readOnly: false,
+ editable: true,
+ pageOptions: {},
+ });
+ view.mountId = mountId;
+ root.setAttribute('data-runtime-mount-id', String(mountId));
+ root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource');
+ root.setAttribute('data-document-id', session.ownerDocumentId || '');
+ root.setAttribute('data-workspace-id', session.workspaceId || '');
+ if (entry.panel instanceof HTMLElement) {
+ const shell = entry.panel.querySelector('.document-shell');
+ if (shell instanceof HTMLElement) {
+ shell.setAttribute('data-document-id', session.ownerDocumentId || '');
+ shell.setAttribute('data-workspace-id', session.workspaceId || '');
+ }
+ }
+ setStatus(runtimeDescriptor, 'mounting-editor');
+ entry.view = view;
+ entry.session = session;
+ markIntendedSlashRoot(entry);
+ };
+
+ const openPassiveResourceTab = (entry, input) => {
+ const href = String(input.officeUrl || input.href || '').trim();
+ if (entry.kind === 'image') {
+ entry.panel.innerHTML = '
';
+ const img = entry.panel.querySelector('img');
+ if (img instanceof HTMLImageElement) {
+ img.src = href;
+ img.alt = entry.title;
+ }
+ return;
+ }
+ entry.panel.innerHTML = '';
+ const frame = entry.panel.querySelector('iframe');
+ if (frame instanceof HTMLIFrameElement) {
+ frame.title = entry.title;
+ frame.src = href;
+ }
+ };
+
+ const refreshExistingOfficeResourceTab = (entry, input) => {
+ if (!entry || entry.kind !== 'office') return false;
+ const nextHref = String(input.officeUrl || input.href || '').trim();
+ if (!nextHref) return false;
+ const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
+ const currentHref = frame instanceof HTMLIFrameElement
+ ? String(frame.getAttribute('src') || frame.src || '').trim()
+ : '';
+ if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
+ return true;
+ };
+
+ const openUnsupportedSideTarget = (input = {}) => {
+ const url = currentUrl();
+ secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
+ const previousView = paneViewRegistry.get('secondary');
+ if (previousView) {
+ unmountEditorViewBinding(previousView);
+ paneViewRegistry.delete('secondary');
+ }
+ 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 = '';
+ 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,
+ };
+};
diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs
index 0c619aec..509cc830 100644
--- a/rust/crates/mnote-web/src/routes/gateway.rs
+++ b/rust/crates/mnote-web/src/routes/gateway.rs
@@ -2806,7 +2806,7 @@ mod tests {
r#""#
));
assert!(
- include_str!("../../browser/document-editor-adapter-runtime.js")
+ include_str!("../../browser/document-resource-tab-runtime.js")
.contains("openResourceInActiveTab")
);
assert!(!html.contains(r#""#));
- let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS;
- assert!(runtime.contains("resourceTabBadgeKind(input, kind)"));
- assert!(runtime.contains("mnote.open_editors_snapshot.v1"));
+ let resource_runtime = DOCUMENT_RESOURCE_TAB_RUNTIME_JS;
+ assert!(runtime.contains("document-resource-tab-runtime.js"));
+ assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
+ assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
- assert!(runtime.contains("bindMainEditorTabStrip"));
- assert!(runtime.contains("data-mnote-tab-strip-bound"));
+ assert!(resource_runtime.contains("bindMainEditorTabStrip"));
+ assert!(resource_runtime.contains("data-mnote-tab-strip-bound"));
assert!(runtime.contains("currentWebShellDocumentId"));
- assert!(runtime.contains(
+ assert!(resource_runtime.contains(
"const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();"
));
- assert!(runtime.contains(
+ assert!(resource_runtime.contains(
"if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;"
));
assert!(!html
@@ -1618,11 +1635,11 @@ mod tests {
assert!(
runtime.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
);
- assert!(runtime.contains("data-mnote-resource-tab-host"));
+ assert!(html.contains("data-mnote-resource-tab-host"));
assert!(runtime.contains("openPrimaryMindmap"));
assert!(runtime.contains("openResourceInActiveTab"));
- assert!(runtime.contains("/api/local-folder/resource/read"));
- assert!(runtime.contains("/api/local-folder/resource/write"));
+ assert!(resource_runtime.contains("/api/local-folder/resource/read"));
+ assert!(resource_runtime.contains("/api/local-folder/resource/write"));
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
@@ -1655,29 +1672,30 @@ mod tests {
assert!(!runtime.contains("mnote-web-document-shell"));
// Resource open resolver contract
- assert!(runtime.contains("normalizeResourceTabKind"));
- assert!(runtime.contains("data-resource-tab-error"));
- assert!(runtime.contains("resourceTabCloseGuardReason"));
- assert!(runtime.contains("closeResourceTab"));
- assert!(runtime.contains("resourceTabRegistry.delete(key)"));
+ assert!(resource_runtime.contains("normalizeResourceTabKind"));
+ assert!(resource_runtime.contains("data-resource-tab-error"));
+ assert!(resource_runtime.contains("resourceTabCloseGuardReason"));
+ assert!(resource_runtime.contains("closeResourceTab"));
+ assert!(resource_runtime.contains("resourceTabRegistry.delete(key)"));
assert!(runtime.contains("data-testid=\"mnote-secondary-editor-tab-host\""));
assert!(html.contains("data-testid=\"mnote-secondary-resource-tab-host\""));
- assert!(runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)"));
+ assert!(resource_runtime.contains("resourceTabRegistryKey(paneRole, objectIdentity)"));
assert!(runtime.contains("openResourceInActiveTab({ ...input, paneRole: 'secondary'"));
- assert!(runtime.contains("data-mnote-editor-kind=\"resource\""));
- assert!(runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);"));
- assert!(runtime.contains("paneRole,"));
- assert!(runtime.contains("markIntendedSlashRoot(entry);"));
- assert!(runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);"));
- assert!(runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
- assert!(runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
- assert!(runtime.contains("resourceTabBadgeKind(input, kind)"));
- assert!(runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>"));
- assert!(runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
+ assert!(resource_runtime.contains("data-mnote-editor-kind=\"resource\""));
+ assert!(resource_runtime.contains("const paneRole = normalizePaneRole(entry.paneRole);"));
+ assert!(resource_runtime.contains("paneRole,"));
+ assert!(resource_runtime.contains("markIntendedSlashRoot(entry);"));
+ assert!(resource_runtime.contains("if (activeEntry) markIntendedSlashRoot(activeEntry);"));
+ assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
+ assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
+ assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(
- runtime.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);")
+ resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
- assert!(runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
+ assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
+ assert!(resource_runtime
+ .contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);"));
+ assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
}
#[tokio::test]
@@ -2098,6 +2116,10 @@ mod tests {
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-pane-runtime.js"));
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("openDocumentInSecondaryPane"));
assert!(DOCUMENT_PANE_RUNTIME_JS.contains("buildPaneRuntime"));
+ assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-resource-tab-runtime.js"));
+ assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("openResourceInActiveTab"));
+ assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("buildOpenEditorsSnapshot"));
+ assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("bindMainEditorTabStrip"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-slash-position-runtime.js"));
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("observeSlashMenuPosition"));
assert!(DOCUMENT_SLASH_POSITION_RUNTIME_JS.contains("scheduleSlashMenuPosition"));