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