Files
mnote/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
T

2473 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
localFileOpenPathFromTiptapHref,
localMarkdownDocumentIdFromRelativePath,
localMarkdownRelativePathFromDocumentId,
localizeTiptapAssetUrls,
} from './document-tiptap-conversion-runtime.js';
/**
* @typedef {Object} MNoteWorkspacePath
* @property {'mnote.workspace_path.v1'} schema
* @property {string} workspaceId
* @property {string} sourceKind
* @property {string} rootUri
* @property {string} relativePath
* @property {string} documentId
* @property {string} objectIdentity
* @property {string} assetId
* @property {string} resourceKind
*
* @typedef {Object} MNoteOpenEditorEntry
* @property {string} objectIdentity
* @property {MNoteWorkspacePath|null} workspacePath
* @property {'primary'|'secondary'} paneRole
* @property {string} editorKind
* @property {boolean} active
* @property {string} dirtyState
* @property {boolean} preview
* @property {boolean} pinned
* @property {number} lastActiveAt
*
* @typedef {Object} MNoteOpenEditorsSnapshot
* @property {'mnote.open_editors_snapshot.v1'} schema
* @property {number} generatedAt
* @property {string} activeObjectIdentity
* @property {MNoteOpenEditorEntry[]} editors
* @property {{primary: Object, secondary: Object}} groups
*/
export const createResourceTabRuntime = (dependencies = {}) => {
const {
loadRuntime,
createEditorViewBinding,
ensureLocalFolderEventChannel,
unmountEditorViewBinding,
setStatus,
documentSessionRegistry,
sessionHasRecentLocalInput,
currentUrl,
replaceUrlState,
applyStoredSecondaryWidth,
workspace,
setSecondaryEditorHostVisible,
markIntendedSlashRoot,
toTiptapDocument,
openMindmapResourceTab,
unmountMindmapPane,
paneViewRegistry,
rootSelector,
currentDocumentId,
currentWorkspaceId,
secondaryQueryParamNames = [],
} = dependencies;
const selector = rootSelector || '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
const resourceTabRegistry = new Map();
const resourceTabMru = { primary: [], secondary: [] };
const resourceTabMruMax = 20;
const readonlyCodePreviewMaxChars = 1_500_000;
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
let onlyofficeBridgeReadyListenerBound = false;
const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
const resourceTabRegistryKey = (paneRole, objectIdentity) => `${normalizePaneRole(paneRole)}::${String(objectIdentity || '').trim()}`;
const resourceTabHostNodes = (paneRole = 'primary') => {
const role = normalizePaneRole(paneRole);
return {
paneRole: role,
strip: document.querySelector(`[data-mnote-main-tab-strip][data-pane-role="${role}"]`),
pageTab: document.querySelector(`[data-mnote-main-tab="page"][data-pane-role="${role}"]`),
pagePanel: document.querySelector(`[data-mnote-page-tab-panel][data-pane-role="${role}"]`),
host: document.querySelector(`[data-mnote-resource-tab-host][data-pane-role="${role}"]`),
panelRoot: document.querySelector(`[data-mnote-resource-tab-panel-root][data-pane-role="${role}"]`),
};
};
const resourceTabBadgeKind = (input, kind) => {
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
if (kind === 'mindmap') return 'mindmap';
if (kind === 'office') {
if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
return 'word';
}
if (kind === 'pdf') return 'pdf';
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code';
if (kind === 'image') return 'image';
return 'file';
};
const resourceTabWidthType = (entry) => {
if (!entry) return '';
const kind = String(entry.kind || '').trim();
if (kind === 'office') {
const badgeKind = String(entry.badgeKind || resourceTabBadgeKind(entry, kind) || '').trim();
if (badgeKind === 'ppt') return 'ppt';
if (badgeKind === 'sheet') return 'excel';
return 'word';
}
if (kind === 'pdf') return 'pdf';
if (kind === 'mindmap') return 'mindmap';
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'markdown';
return '';
};
const currentWebShellWorkspaceId = () => {
const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
if (explicit) return explicit;
const fromBody = document.body?.dataset?.workspaceId || '';
if (fromBody) return String(fromBody).trim();
const shell = document.querySelector('.document-shell[data-workspace-id], [data-document-pane="true"][data-pane-role="primary"][data-pane-workspace-id]');
if (shell instanceof HTMLElement) {
const value = shell.getAttribute('data-workspace-id') || shell.getAttribute('data-pane-workspace-id') || '';
if (value) return value.trim();
}
try {
return currentUrl().searchParams.get('workspaceId') || '';
} catch (_) {
return '';
}
};
const currentFileTreeWorkspacePath = () => {
try {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path]');
const reader = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow;
if (row instanceof HTMLElement && typeof reader === 'function') {
const workspacePath = reader(row);
if (workspacePath && typeof workspacePath === 'object') {
return {
...workspacePath,
sourceKind: String(workspacePath.sourceKind || row.getAttribute('data-source-kind') || '').trim(),
rootUri: String(workspacePath.rootUri || row.getAttribute('data-root-uri') || '').trim(),
};
}
}
if (row instanceof HTMLElement) {
return {
sourceKind: String(row.getAttribute('data-source-kind') || '').trim(),
rootUri: String(row.getAttribute('data-root-uri') || '').trim(),
};
}
} catch (_) {}
return null;
};
const currentWebShellDocumentId = () => {
const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : '';
if (explicit) return explicit;
const fromBody = document.body?.dataset?.documentId || '';
if (fromBody) return String(fromBody).trim();
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
return '';
};
const documentIdForPane = (paneRole) => {
const role = normalizePaneRole(paneRole);
const nodes = resourceTabHostNodes(role);
const fromTab = String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim();
if (fromTab) return fromTab;
const pane = document.querySelector(`[data-document-pane="true"][data-pane-role="${role}"]`);
if (pane instanceof HTMLElement) {
const fromPane = String(pane.getAttribute('data-pane-document-id') || '').trim();
if (fromPane) return fromPane;
const root = pane.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-document-id], .document-shell[data-document-id]');
if (root instanceof HTMLElement) {
const fromRoot = String(root.getAttribute('data-document-id') || '').trim();
if (fromRoot) return fromRoot;
}
}
return role === 'primary' ? currentWebShellDocumentId() : '';
};
const currentWebShellSourceKind = () => {
try {
return currentUrl().searchParams.get('sourceKind')
|| document.body?.dataset?.mnoteSourceKind
|| currentFileTreeWorkspacePath()?.sourceKind
|| '';
} catch (_) {
return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || '';
}
};
const currentWebShellRootUri = () => {
try {
return currentUrl().searchParams.get('rootUri')
|| document.body?.dataset?.mnoteRootUri
|| currentFileTreeWorkspacePath()?.rootUri
|| '';
} catch (_) {
return document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || '';
}
};
const buildWorkspacePath = ({
workspacePath,
workspaceId,
sourceKind,
rootUri,
relativePath,
documentId,
objectIdentity,
assetId,
resourceKind,
} = {}) => {
const fromInput = workspacePath && typeof workspacePath === 'object' ? workspacePath : null;
if (fromInput && String(fromInput.schema || '') === 'mnote.workspace_path.v1') {
return {
schema: 'mnote.workspace_path.v1',
workspaceId: String(fromInput.workspaceId || workspaceId || '').trim(),
sourceKind: String(fromInput.sourceKind || sourceKind || '').trim(),
rootUri: String(fromInput.rootUri || rootUri || '').trim(),
relativePath: String(fromInput.relativePath || fromInput.localRelativePath || relativePath || '').trim(),
documentId: String(fromInput.documentId || documentId || '').trim(),
objectIdentity: fromInput.objectIdentity ?? String(objectIdentity || '').trim(),
assetId: String(fromInput.assetId || assetId || '').trim(),
resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(),
};
}
const structuredObjectIdentity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : null;
const objectIdentityText = structuredObjectIdentity ? '' : String(objectIdentity || '').trim();
const id = String(objectIdentityText || documentId || assetId || relativePath || '').trim();
if (!id) return null;
return {
schema: 'mnote.workspace_path.v1',
workspaceId: String(workspaceId || '').trim(),
sourceKind: String(sourceKind || '').trim(),
rootUri: String(rootUri || '').trim(),
relativePath: String(relativePath || '').trim(),
documentId: String(documentId || '').trim(),
objectIdentity: structuredObjectIdentity || objectIdentityText,
assetId: String(assetId || '').trim(),
resourceKind: String(resourceKind || '').trim(),
};
};
const normalizeResourceTabKind = (input) => {
const kind = String(input?.kind || '').trim().toLowerCase();
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
if (/\.pdf$/i.test(title)) return 'pdf';
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
if (/\.(md|markdown)$/i.test(title)) return 'markdown';
if (/\.(txt|log)$/i.test(title)) return 'text';
if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code';
return 'file';
};
const resolveResourceOpen = (input = {}) => {
const kind = normalizeResourceTabKind(input);
const badgeKind = resourceTabBadgeKind(input, kind);
const rawTarget = String(input?.openTarget || '').trim().toLowerCase();
const openTarget = rawTarget === 'new-window' || rawTarget === 'side' ? rawTarget : 'active-tab';
const editable = kind === 'markdown' || kind === 'text';
const defaultOpenMode = kind === 'office' && openTarget === 'active-tab' ? 'active-tab-iframe' : openTarget;
const viewerUrl = kind === 'office'
? String(input?.officeUrl || input?.href || '').trim()
: String(input?.href || '').trim();
return { editorKind: kind, badgeKind, defaultOpenMode, editable, viewerUrl, openTarget };
};
const touchResourceTabMru = (paneRole, key) => {
const role = normalizePaneRole(paneRole);
const id = String(key || '').trim();
if (!id) return;
const list = resourceTabMru[role] || (resourceTabMru[role] = []);
const index = list.indexOf(id);
if (index >= 0) list.splice(index, 1);
list.unshift(id);
if (list.length > resourceTabMruMax) list.length = resourceTabMruMax;
};
const setResourceTabLastActive = (key) => {
const entry = resourceTabRegistry.get(String(key || '').trim());
if (!entry) return;
const timestamp = Date.now();
entry.lastActiveAt = timestamp;
if (entry.session) entry.session.lastActiveAt = timestamp;
};
const removeFromResourceTabMru = (paneRole, key) => {
const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
const index = list.indexOf(key);
if (index >= 0) list.splice(index, 1);
};
const lastActiveResourceTabKey = (paneRole = 'primary') => {
const list = resourceTabMru[normalizePaneRole(paneRole)] || [];
for (const key of list) {
if (resourceTabRegistry.has(key)) return key;
}
return '';
};
const resourceTabCloseGuardReason = (session) => {
if (!session) return '';
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') {
return bufferDirtyState;
}
const hasUnsavedChanges = session.dirty
|| Boolean(session.saveTimer)
|| sessionHasRecentLocalInput(session)
|| (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized);
if (hasUnsavedChanges) return 'dirty';
if (session.saving) return 'saving';
if (session.hasExternalConflict) return 'hasExternalConflict';
return '';
};
const documentSessionForPane = (paneRole, documentId) => {
const id = String(documentId || '').trim();
if (!id || !documentSessionRegistry || typeof documentSessionRegistry.values !== 'function') return null;
const role = normalizePaneRole(paneRole);
return Array.from(documentSessionRegistry.values()).find((session) => (
session
&& session.sessionKind !== 'resource'
&& String(session.documentId || '').trim() === id
&& Array.from(session.views?.values?.() || []).some((view) => normalizePaneRole(view?.runtimeDescriptor?.paneRole) === role)
)) || Array.from(documentSessionRegistry.values()).find((session) => (
session
&& session.sessionKind !== 'resource'
&& String(session.documentId || '').trim() === id
)) || null;
};
const documentSessionDirtyState = (session) => {
if (!session) return '';
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') return bufferDirtyState;
if (session.hasExternalConflict) return 'ExternalModified';
if (session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session)) return 'Dirty';
if (session.saving) return 'Saving';
return '';
};
const resourceTabBufferStateUrl = (entry) => {
const session = entry?.session;
if (!session || session.sourceKind !== 'local_folder') return null;
const relativePath = String(session.resourcePath || entry?.workspacePath?.relativePath || '').trim();
const rootUri = String(session.rootUri || entry?.workspacePath?.rootUri || '').trim();
if (!relativePath || !rootUri) return null;
const url = new URL('/api/documents/buffer-state', window.location.origin);
url.searchParams.set('documentId', String(session.documentId || entry.objectIdentity || ''));
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('relativePath', relativePath);
const workspaceId = String(session.workspaceId || entry?.workspacePath?.workspaceId || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
return url;
};
const applyResourceTabCloseGuard = (entry) => {
if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
const reason = resourceTabCloseGuardReason(entry.session);
if (reason) {
entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason);
entry.tab.classList.add('is-close-guarded');
} else {
entry.tab.removeAttribute(resourceTabCloseGuardAttribute);
entry.tab.classList.remove('is-close-guarded');
}
};
const refreshResourceTabBufferState = async (entry) => {
const url = resourceTabBufferStateUrl(entry);
if (!url || !entry?.session) return null;
try {
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true || !payload.result) return null;
const dirtyState = String(payload.result.dirtyState || '').trim();
if (dirtyState) entry.session.bufferDirtyState = dirtyState;
if (typeof payload.result.fileVersion === 'string' && payload.result.fileVersion.trim()) {
entry.session.fileVersion = payload.result.fileVersion.trim();
}
applyResourceTabCloseGuard(entry);
return payload.result;
} catch (_) {
return null;
}
};
const syncResourceTabCloseGuard = (entry) => {
applyResourceTabCloseGuard(entry);
void refreshResourceTabBufferState(entry);
};
const syncResourceSessionTabGuards = (session) => {
if (!session || session.sessionKind !== 'resource') return;
resourceTabRegistry.forEach((entry) => {
if (entry.session === session) syncResourceTabCloseGuard(entry);
});
};
const officeBridgeDebugForEntry = (entry) => {
if (!entry?.panel || !(entry.panel instanceof HTMLElement)) return null;
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
if (!(frame instanceof HTMLIFrameElement)) return null;
try {
const debug = frame.contentWindow?.__MNOTE_ONLYOFFICE_DEBUG__;
return debug && typeof debug === 'object' ? debug : null;
} catch (_) {
return null;
}
};
const officeOpenModeForEntry = (entry) => {
if (!entry || normalizeResourceTabKind(entry) !== 'office') return '';
const href = String(entry.passiveFrameSrc || entry.officeUrl || entry.href || '').trim()
|| String(entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame')?.getAttribute?.('src') || '').trim();
if (href) {
try {
const url = new URL(href, window.location.origin);
if (url.pathname === '/office-preview' || url.pathname === '/office-n') return 'preview';
if (url.pathname === '/onlyoffice' || url.pathname.startsWith('/office/')) return 'onlyoffice_live';
} catch (_) {}
}
if (entry.onlyofficeSessionId || entry.bridgeSessionId) return 'onlyoffice_live';
return 'preview';
};
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
const active = entry?.tab instanceof HTMLElement
? entry.tab.getAttribute('aria-selected') === 'true'
: false;
const documentId = String(entry?.documentId || entry?.ownerDocumentId || entry?.session?.ownerDocumentId || '').trim();
const workspaceId = String(entry?.workspaceId || entry?.session?.workspaceId || currentWebShellWorkspaceId() || '').trim();
const sourceKind = String(entry?.sourceKind || entry?.session?.sourceKind || currentWebShellSourceKind() || '').trim();
const rootUri = String(entry?.rootUri || entry?.session?.rootUri || currentWebShellRootUri() || '').trim();
const relativePath = String(entry?.path || entry?.session?.resourcePath || '').trim();
const objectIdentity = String(entry?.objectIdentity || key || '').trim();
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
const kind = normalizeResourceTabKind(entry);
const dirtyState = resourceTabCloseGuardReason(entry?.session);
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
const officeOpenMode = kind === 'office' ? officeOpenModeForEntry(entry) : '';
const onlyofficeSessionId = String(
officeBridgeDebug?.bridgeSessionId
|| entry?.onlyofficeSessionId
|| entry?.bridgeSessionId
|| '',
).trim();
const snapshotResourceKind = kind === 'office'
? (officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment')
: kind;
const workspacePathSeed = entry?.workspacePath && typeof entry.workspacePath === 'object'
? { ...entry.workspacePath, resourceKind: snapshotResourceKind }
: entry?.workspacePath;
return {
objectIdentity,
workspacePath: buildWorkspacePath({
workspacePath: workspacePathSeed,
workspaceId,
sourceKind,
rootUri,
relativePath,
documentId,
objectIdentity,
assetId,
resourceKind: snapshotResourceKind,
}),
paneRole: normalizePaneRole(entry?.paneRole),
documentId,
workspaceId,
title: String(entry?.title || '资源').trim() || '资源',
kind,
editorKind: kind,
badgeKind: entry?.tab instanceof HTMLElement
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
: resourceTabBadgeKind(entry, entry?.kind),
active,
dirtyState,
dirtyGuard: dirtyState,
assetId,
path: relativePath,
officeOpenMode,
onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
bridgeSessionReady: Boolean(onlyofficeSessionId),
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
preview: officeOpenMode === 'preview',
pinned: false,
};
};
const buildOpenEditorsSnapshot = () => {
const generatedAt = Date.now();
const pageEntries = ['primary', 'secondary'].map((paneRole) => {
const nodes = resourceTabHostNodes(paneRole);
const documentId = documentIdForPane(paneRole);
const workspaceId = String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim();
const sourceKind = currentWebShellSourceKind();
const rootUri = currentWebShellRootUri();
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
const objectIdentity = `page:${paneRole}`;
const workspaceObjectIdentity = {
objectKind: 'page',
documentId,
blockId: null,
assetId: null,
};
const active = nodes.pageTab instanceof HTMLElement
? nodes.pageTab.getAttribute('aria-selected') === 'true'
: false;
const pageSession = documentSessionForPane(paneRole, documentId);
const dirtyState = documentSessionDirtyState(pageSession);
const pageTitle = nodes.pageTab instanceof HTMLElement
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
: '页面';
return {
objectIdentity,
workspacePath: buildWorkspacePath({
workspaceId,
sourceKind,
rootUri,
relativePath,
documentId,
objectIdentity: workspaceObjectIdentity,
assetId: '',
resourceKind: 'page',
}),
paneRole,
documentId,
workspaceId,
title: pageTitle || '页面',
kind: 'page',
editorKind: 'page',
badgeKind: 'code',
active,
dirtyState,
dirtyGuard: dirtyState,
lastActiveAt: active ? generatedAt : 0,
preview: false,
pinned: true,
};
}).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
const resources = [];
resourceTabRegistry.forEach((entry, key) => {
resources.push(openEditorsSnapshotEntry(entry, key, generatedAt));
});
const editors = [...pageEntries, ...resources];
const groupForPane = (paneRole) => {
const role = normalizePaneRole(paneRole);
const groupEditors = editors.filter((entry) => normalizePaneRole(entry.paneRole) === role);
const groupResources = resources.filter((entry) => normalizePaneRole(entry.paneRole) === role);
const groupActive = groupEditors.find((entry) => entry.active);
return {
paneRole: role,
activeObjectIdentity: groupActive?.objectIdentity || '',
editors: groupEditors,
resourceEditors: groupResources,
};
};
const groups = {
primary: groupForPane('primary'),
secondary: groupForPane('secondary'),
};
const activeObjectIdentity = groups.primary.activeObjectIdentity || groups.secondary.activeObjectIdentity || '';
return {
schema: 'mnote.open_editors_snapshot.v1',
generatedAt,
activeObjectIdentity,
editors,
resourceEditors: resources,
groups,
};
};
const syncOpenEditorsSnapshot = () => {
const snapshot = buildOpenEditorsSnapshot();
window.__mnoteOpenEditorsSnapshot = snapshot;
document.documentElement.setAttribute('data-mnote-open-editors-count', String(snapshot.editors.length));
document.documentElement.setAttribute('data-mnote-active-editor', snapshot.activeObjectIdentity || '');
window.dispatchEvent(new CustomEvent('mnote:open-editors-snapshot', { detail: snapshot }));
return snapshot;
};
const showResourceTabCloseGuardNotice = (entry, reason) => {
const nodes = resourceTabHostNodes(entry?.paneRole || 'primary');
const messages = {
dirty: '当前资源有未保存的修改,保存完成后再关闭。',
saving: '当前资源正在保存中,请稍后再关闭。',
hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。',
};
const message = messages[reason] || '当前资源暂时无法关闭。';
let notice = document.getElementById('mnote-resource-close-guard-notice');
if (!notice) {
notice = document.createElement('div');
notice.id = 'mnote-resource-close-guard-notice';
notice.className = 'mnote-close-guard-notice';
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
notice.setAttribute('data-mnote-resource-close-guard', '');
const parent = nodes.strip?.parentNode;
if (parent instanceof HTMLElement) {
const panels = parent.querySelector('.mnote-main-tab-panels');
if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels);
else parent.append(notice);
}
}
notice.textContent = `${entry?.title || '资源'}${message}`;
notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked');
notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`;
if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer);
notice._mnoteHideTimer = window.setTimeout(() => {
notice.classList.add('is-hiding');
window.setTimeout(() => {
if (notice.parentNode) notice.remove();
}, 260);
}, 4000);
};
const cssSafe = (value) => {
const text = String(value || '');
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
return text.replace(/["\\]/g, '\\$&');
};
const syncActiveResourceFileTreeRow = (activeResource) => {
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-active', 'false');
});
const entry = activeResource ? resourceTabRegistry.get(activeResource) : null;
if (!entry) return;
const assetId = String(entry.assetId || entry.session?.assetId || '').trim();
const path = String(entry.path || entry.session?.resourcePath || '').trim();
const identity = String(entry.objectIdentity || activeResource || '').trim();
if (assetId) {
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`);
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'true');
return;
}
}
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]');
for (const row of rows) {
if (!(row instanceof HTMLElement)) continue;
const objectIdentity = row.getAttribute('data-object-identity') || '';
if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) {
row.setAttribute('data-active', 'true');
return;
}
}
};
const syncActiveResourceWidthType = (activeEntry, paneRole = 'primary') => {
const role = normalizePaneRole(paneRole);
const widthType = role === 'primary' ? resourceTabWidthType(activeEntry) : '';
const targetNodes = [document.documentElement, document.body].filter((node) => node instanceof HTMLElement);
targetNodes.forEach((node) => {
if (widthType) node.setAttribute('data-mnote-active-resource-width-type', widthType);
else node.removeAttribute('data-mnote-active-resource-width-type');
});
const shell = document.querySelector(`.document-pane[data-pane-role="${role}"] .document-shell`);
if (shell instanceof HTMLElement) {
if (widthType) shell.setAttribute('data-mnote-active-resource-width-type', widthType);
else shell.removeAttribute('data-mnote-active-resource-width-type');
}
window.dispatchEvent(new CustomEvent('mnote:active-resource-tab-changed', {
detail: {
paneRole: role,
active: Boolean(activeEntry),
widthType,
objectIdentity: String(activeEntry?.objectIdentity || '')
}
}));
};
const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
const role = normalizePaneRole(paneRole);
if (role !== 'primary') return;
const url = currentUrl();
if (activeResource) {
const entry = resourceTabRegistry.get(activeResource);
const documentId = String(entry?.ownerDocumentId || entry?.documentId || '').trim();
if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
url.searchParams.set('resourceTab', activeResource);
} else {
const documentId = currentWebShellDocumentId();
if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;
url.searchParams.delete('resourceTab');
}
replaceUrlState(url);
};
const activateMainEditorTab = (objectIdentity, paneRole = 'primary') => {
const activeResource = String(objectIdentity || '').trim();
const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null;
const role = normalizePaneRole(activeEntry?.paneRole || paneRole);
const nodes = resourceTabHostNodes(role);
if (activeResource) {
touchResourceTabMru(role, activeResource);
setResourceTabLastActive(activeResource);
}
if (nodes.pageTab instanceof HTMLElement) {
const activePage = !activeResource;
nodes.pageTab.classList.toggle('is-active', activePage);
nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
}
if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
resourceTabRegistry.forEach((entry, key) => {
if (normalizePaneRole(entry.paneRole) !== role) return;
const active = key === activeResource;
if (entry.tab instanceof HTMLElement) {
entry.tab.classList.toggle('is-active', active);
entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
entry.tab.setAttribute('tabindex', active ? '0' : '-1');
}
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
syncResourceTabCloseGuard(entry);
});
syncActiveResourceWidthType(activeEntry, role);
if (activeEntry) markIntendedSlashRoot(activeEntry);
if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
syncActiveResourceUrlState(activeResource, role);
syncActivePassiveResourceWatch(role, activeResource);
syncOpenEditorsSnapshot();
};
const bindMainEditorTabStrip = (paneRole = 'primary') => {
const nodes = resourceTabHostNodes(paneRole);
if (!(nodes.strip instanceof HTMLElement)) return;
if (nodes.strip.getAttribute('data-mnote-tab-strip-bound') === 'true') return;
nodes.strip.setAttribute('data-mnote-tab-strip-bound', 'true');
const collectTabs = () => Array.from(nodes.strip.querySelectorAll('[data-mnote-main-tab]'))
.filter((tab) => tab instanceof HTMLElement && tab.isConnected);
const selectedIndex = (tabs) => tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
nodes.strip.addEventListener('keydown', (event) => {
const tabs = collectTabs();
if (!tabs.length) return;
const focusedIndex = tabs.findIndex((tab) => tab === document.activeElement);
const baseIndex = focusedIndex >= 0 ? focusedIndex : Math.max(0, selectedIndex(tabs));
let targetIndex = -1;
if (event.key === 'ArrowRight') {
targetIndex = (baseIndex + 1) % tabs.length;
} else if (event.key === 'ArrowLeft') {
targetIndex = (baseIndex - 1 + tabs.length) % tabs.length;
} else if (event.key === 'Home') {
targetIndex = 0;
} else if (event.key === 'End') {
targetIndex = tabs.length - 1;
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const tab = tabs[baseIndex];
if (tab instanceof HTMLElement) tab.click();
return;
} else {
return;
}
event.preventDefault();
const target = tabs[targetIndex];
if (target instanceof HTMLElement) target.focus();
});
};
const bindMainEditorPageTab = (paneRole = 'primary') => {
const role = normalizePaneRole(paneRole);
const nodes = resourceTabHostNodes(role);
if (!(nodes.pageTab instanceof HTMLElement)) return;
bindMainEditorTabStrip(role);
if (nodes.pageTab.getAttribute('data-mnote-page-tab-bound') === 'true') return;
nodes.pageTab.setAttribute('data-mnote-page-tab-bound', 'true');
nodes.pageTab.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.closest('[data-mnote-pane-close="secondary"]')) return;
event.preventDefault();
activateMainEditorTab('', role);
});
syncOpenEditorsSnapshot();
};
const closeResourceTab = async (objectIdentity) => {
const key = String(objectIdentity || '').trim();
const entry = resourceTabRegistry.get(key);
if (!entry) return;
await refreshResourceTabBufferState(entry);
const guardReason = resourceTabCloseGuardReason(entry.session);
if (guardReason) {
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
syncResourceTabCloseGuard(entry);
showResourceTabCloseGuardNotice(entry, guardReason);
return;
}
removeFromResourceTabMru(entry.paneRole || 'primary', key);
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
try {
entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
} catch (error) {
console.warn('mnote mindmap resource tab unmount failed', error);
}
}
releaseInlinePdfResource(entry);
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.setAttribute('data-resource-path', String(input.path || '').trim());
panel.hidden = true;
nodes.strip.append(tab);
nodes.panelRoot.append(panel);
return {
objectIdentity,
registryKey,
paneRole,
title,
kind,
badgeKind: resourceTabBadgeKind(input, kind),
tab,
panel,
view: null,
session: null,
assetId: String(input.assetId || '').trim(),
path: String(input.path || '').trim(),
documentId: String(input.documentId || '').trim(),
ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(),
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || '').trim(),
sourceKind: String(input.sourceKind || currentWebShellSourceKind() || '').trim(),
rootUri: String(input.rootUri || currentWebShellRootUri() || '').trim(),
workspacePath: buildWorkspacePath({
workspacePath: input.workspacePath,
workspaceId: input.workspaceId || currentWebShellWorkspaceId(),
sourceKind: input.sourceKind || currentWebShellSourceKind(),
rootUri: input.rootUri || currentWebShellRootUri(),
relativePath: input.path,
documentId: input.documentId,
objectIdentity,
assetId: input.assetId,
resourceKind: kind,
}),
lastActiveAt: 0,
};
};
const releaseResourceTabEntryRuntime = (entry) => {
if (!entry) return;
releaseInlinePdfResource(entry);
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;
}
closePassiveResourceWatch(entry);
if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren();
};
const localResourceReadUrl = (rootUri, path) => {
const url = new URL('/api/local-folder/resource/read', window.location.origin);
url.searchParams.set('rootUri', rootUri || '');
url.searchParams.set('path', path || '');
return url.toString();
};
const localResourceStatUrl = (rootUri, path) => {
const url = new URL('/api/local-folder/files/stat', window.location.origin);
url.searchParams.set('rootUri', rootUri || '');
url.searchParams.set('path', path || '');
return url.toString();
};
const withResourceReloadToken = (href) => {
if (!href) return href;
const url = new URL(href, window.location.origin);
url.searchParams.set('mnoteResourceReload', String(Date.now()));
return url.toString();
};
const normalizeEvidenceBBox = (value) => {
if (!value) return null;
if (Array.isArray(value) && value.length >= 4) {
const values = value.slice(0, 4).map((item) => Number(item));
return values.every(Number.isFinite) ? { x0: values[0], y0: values[1], x1: values[2], y1: values[3] } : null;
}
if (typeof value === 'object') {
const bbox = {
x0: Number(value.x0),
y0: Number(value.y0),
x1: Number(value.x1),
y1: Number(value.y1),
};
return Object.values(bbox).every(Number.isFinite) ? bbox : null;
}
if (typeof value === 'string') {
const parts = value.split(',').map((item) => Number(item.trim()));
return parts.length >= 4 && parts.slice(0, 4).every(Number.isFinite)
? { x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }
: null;
}
return null;
};
const normalizeEvidenceLocatorInput = (input = {}) => {
const locator = input.evidenceLocator && typeof input.evidenceLocator === 'object'
? input.evidenceLocator
: input.locator && typeof input.locator === 'object'
? input.locator
: null;
const params = locator?.openAction?.params && typeof locator.openAction.params === 'object' ? locator.openAction.params : {};
const page = Number(input.page ?? locator?.page ?? params.page);
const bbox = normalizeEvidenceBBox(input.bbox ?? locator?.bbox ?? params.bbox);
const sourceMapPath = String(input.sourceMapPath || locator?.sourceMapPath || params.sourceMapPath || '').trim();
const blockId = String(input.blockId || locator?.blockId || params.blockId || '').trim();
const evidenceText = String(input.evidenceText || input.query || locator?.evidenceText || locator?.query || params.evidenceText || params.query || '').trim();
const searchQuery = String(input.searchQuery || locator?.searchQuery || params.searchQuery || '').trim();
const lineRange = input.lineRange || locator?.lineRange || params.lineRange || null;
const charRange = input.charRange || locator?.charRange || params.charRange || null;
if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !evidenceText && !lineRange && !charRange) return null;
return {
schema: 'mnote.evidence_locator.v1',
...(locator || {}),
page: Number.isFinite(page) ? page : null,
bbox,
sourceMapPath,
blockId,
evidenceText,
searchQuery,
lineRange,
charRange,
};
};
const evidenceBBoxParam = (bbox) => {
const normalized = normalizeEvidenceBBox(bbox);
return normalized ? [normalized.x0, normalized.y0, normalized.x1, normalized.y1].join(',') : '';
};
const applyEvidenceLocatorToFrame = (frame, locator) => {
if (!(frame instanceof HTMLIFrameElement) || !locator) return;
try {
const url = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin);
if (Number.isFinite(Number(locator.page))) url.searchParams.set('page', String(Number(locator.page)));
const bbox = evidenceBBoxParam(locator.bbox);
if (bbox) url.searchParams.set('bbox', bbox);
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId));
if (locator.evidenceText) url.searchParams.set('evidenceText', String(locator.evidenceText));
if (locator.searchQuery) url.searchParams.set('searchQuery', String(locator.searchQuery));
if (url.pathname === '/office-preview' && frame.contentWindow) {
const currentSrc = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin);
if (officePreviewBaseHref(currentSrc.toString()) !== officePreviewBaseHref(url.toString())) {
frame.src = url.toString();
return;
}
const frameDoc = frame.contentDocument || null;
const previewReady = frameDoc?.documentElement?.getAttribute('data-mnote-office-preview-status') === '完成'
|| (frameDoc?.readyState === 'complete' && Boolean(frameDoc?.body?.dataset?.fileUrl));
if (!previewReady) {
frame.src = url.toString();
return;
}
frame.contentWindow.postMessage({
type: 'mnote:office-evidence-locator',
page: locator.page,
bbox,
sourceMapPath: locator.sourceMapPath || '',
blockId: locator.blockId || '',
evidenceText: locator.evidenceText || '',
searchQuery: locator.searchQuery || '',
}, window.location.origin);
return;
}
frame.src = url.toString();
} catch (_) {}
};
const applyEvidenceLocatorToImagePanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
const bbox = normalizeEvidenceBBox(locator.bbox);
let overlay = entry.panel.querySelector('[data-mnote-evidence-bbox-highlight]');
if (!bbox) {
if (overlay instanceof HTMLElement) overlay.hidden = true;
return;
}
if (!(overlay instanceof HTMLElement)) {
overlay = document.createElement('div');
overlay.className = 'mnote-resource-tab-bbox-highlight';
overlay.setAttribute('data-mnote-evidence-bbox-highlight', 'true');
entry.panel.append(overlay);
}
overlay.hidden = false;
overlay.style.left = `${Math.max(0, bbox.x0)}px`;
overlay.style.top = `${Math.max(0, bbox.y0)}px`;
overlay.style.width = `${Math.max(1, bbox.x1 - bbox.x0)}px`;
overlay.style.height = `${Math.max(1, bbox.y1 - bbox.y0)}px`;
};
const applyEvidenceLocatorToTextPanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
if (!(root instanceof HTMLElement)) return;
const selector = locator.blockId ? `[data-block-id="${cssSafe(locator.blockId)}"]` : '';
let target = selector ? root.querySelector(selector) : null;
const query = String(locator?.openAction?.params?.query || locator?.query || '').trim();
if (!(target instanceof HTMLElement) && query) {
target = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
.find((node) => node instanceof HTMLElement && String(node.textContent || '').includes(query)) || null;
}
const lineStart = Number(locator?.lineRange?.start ?? locator?.line_range?.start ?? 0);
if (!(target instanceof HTMLElement) && Number.isFinite(lineStart) && lineStart > 0) {
const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]'))
.filter((node) => node instanceof HTMLElement);
target = blocks[Math.max(0, Math.min(blocks.length - 1, lineStart - 1))] || null;
}
if (!(target instanceof HTMLElement)) return;
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
});
target.setAttribute('data-mnote-evidence-text-highlight', 'true');
target.scrollIntoView({ block: 'center', inline: 'nearest' });
};
const applyEvidenceLocatorToInlinePdfPanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
const pageNumber = Number(locator.page || 0);
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return;
const canvas = entry.panel.querySelector(`canvas.mnote-pdf-page[data-page-number="${pageNumber}"]`);
if (!(canvas instanceof HTMLCanvasElement)) return;
entry.panel.querySelectorAll('canvas.mnote-pdf-page[data-mnote-evidence-page="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-page');
});
canvas.setAttribute('data-mnote-evidence-page', 'true');
canvas.scrollIntoView({ block: 'center', inline: 'nearest' });
};
const applyEvidenceLocatorToEntry = (entry, input = {}) => {
if (!(entry?.panel instanceof HTMLElement)) return;
const locator = normalizeEvidenceLocatorInput(input);
if (!locator) return;
entry.evidenceLocator = locator;
entry.panel.setAttribute('data-mnote-evidence-locator', JSON.stringify(locator));
entry.panel.setAttribute('data-mnote-evidence-open', 'true');
if (Number.isFinite(Number(locator.page))) entry.panel.setAttribute('data-mnote-evidence-page', String(Number(locator.page)));
if (locator.blockId) entry.panel.setAttribute('data-mnote-evidence-block-id', String(locator.blockId));
if (locator.evidenceText) entry.panel.setAttribute('data-mnote-evidence-text', String(locator.evidenceText).slice(0, 240));
if (locator.sourceMapPath) entry.panel.setAttribute('data-mnote-evidence-source-map-path', String(locator.sourceMapPath));
const bbox = evidenceBBoxParam(locator.bbox);
if (bbox) entry.panel.setAttribute('data-mnote-evidence-bbox', bbox);
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
if (frame instanceof HTMLIFrameElement) applyEvidenceLocatorToFrame(frame, locator);
if (entry.kind === 'image') applyEvidenceLocatorToImagePanel(entry, locator);
if (entry.kind === 'pdf') applyEvidenceLocatorToInlinePdfPanel(entry, locator);
if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 80);
window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 450);
}
};
const renderPassiveResourceMissing = (entry, message) => {
if (!(entry?.panel instanceof HTMLElement)) return;
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
entry.panel.innerHTML = '<div class="mnote-resource-tab-error" data-resource-tab-error="true" data-mnote-resource-missing="true"><div class="mnote-resource-tab-error-inner"><h1>资源已不可用</h1><p></p></div></div>';
const text = entry.panel.querySelector('p');
if (text instanceof HTMLElement) text.textContent = message || '本地资源文件已被删除或移动。';
};
const reloadPassiveResourceTab = (entry) => {
if (!(entry?.panel instanceof HTMLElement)) return;
entry.panel.removeAttribute('data-mnote-resource-missing');
const img = entry.panel.querySelector('img.mnote-resource-tab-image');
if (img instanceof HTMLImageElement) {
img.src = withResourceReloadToken(img.getAttribute('src') || img.src || '');
return;
}
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
if (frame instanceof HTMLIFrameElement) {
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
return;
}
if (entry.kind === 'pdf' && entry.inlinePdfSourceHref) {
void openPassiveResourceTab(entry, { ...(entry.lastPassiveInput || {}), href: withResourceReloadToken(entry.inlinePdfSourceHref) });
}
};
const closePassiveResourceWatch = (entry) => {
if (!entry?.resourceWatchEventSource) return;
try {
entry.resourceWatchEventSource.close();
} catch (_) {}
entry.resourceWatchEventSource = null;
if (entry.panel instanceof HTMLElement) entry.panel.removeAttribute('data-mnote-resource-watch-ready');
};
const isActiveResourceTabEntry = (entry) => {
if (!(entry?.panel instanceof HTMLElement)) return false;
if (entry.panel.hidden === false) return true;
return entry.tab instanceof HTMLElement && entry.tab.getAttribute('aria-selected') === 'true';
};
const closeInactivePassiveResourceWatches = (activeEntry) => {
const role = normalizePaneRole(activeEntry?.paneRole || 'primary');
resourceTabRegistry.forEach((entry) => {
if (entry === activeEntry) return;
if (normalizePaneRole(entry?.paneRole) !== role) return;
closePassiveResourceWatch(entry);
});
};
const resourceChangedPathsFromDetail = (detail) => {
const payload = detail?.payload && typeof detail.payload === 'object' ? detail.payload : detail;
const changedPaths = Array.isArray(detail?.changedPaths)
? detail.changedPaths
: (Array.isArray(payload?.changedPaths) ? payload.changedPaths : []);
return changedPaths.map((item) => {
if (typeof item === 'string') return item.trim();
if (!item || typeof item !== 'object') return '';
return String(item.relativePath || item.relative_path || item.path || '').trim();
}).filter(Boolean);
};
const resourceEventMatchesPath = (detail, rootUri, path) => {
const eventRootUri = String(detail?.rootUri || detail?.payload?.rootUri || '').trim();
if (eventRootUri && eventRootUri !== rootUri) return false;
const changedPaths = resourceChangedPathsFromDetail(detail);
return changedPaths.length === 0 || changedPaths.includes(path);
};
const refreshPassiveResourceEntryFromWatch = async (entry, rootUri, path) => {
try {
const response = await fetch(localResourceStatUrl(rootUri, path), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists);
if (!exists) {
renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。');
return;
}
reloadPassiveResourceTab(entry);
} catch (error) {
renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error));
}
};
const installPassiveResourceWatch = (entry) => {
if (!entry || entry.session) return;
const rootUri = String(entry.rootUri || '').trim();
const path = String(entry.path || '').trim();
if (!rootUri || !path) return;
if (!isActiveResourceTabEntry(entry)) {
closePassiveResourceWatch(entry);
return;
}
closeInactivePassiveResourceWatches(entry);
if (entry.resourceWatchEventSource) {
try {
entry.resourceWatchEventSource.close();
} catch (_) {}
entry.resourceWatchEventSource = null;
}
const eventBus = window.__mnoteLocalFolderEventBus;
if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') {
const workspaceId = String(currentWebShellWorkspaceId() || '').trim();
eventBus.startLocalFolderWatcher({
rootUri,
workspaceId,
bootstrap: {
schema: 'mnote.resource_tab.local_folder_event_bus.v1',
transport: 'local-folder-events',
workspaceId,
},
});
const handler = (event) => {
const detail = event?.detail || {};
if (!resourceEventMatchesPath(detail, rootUri, path)) return;
void refreshPassiveResourceEntryFromWatch(entry, rootUri, path);
};
window.addEventListener('mnote:local-folder:resource-changed', handler);
entry.resourceWatchEventSource = {
close: () => window.removeEventListener('mnote:local-folder:resource-changed', handler),
};
if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'event-bus');
return;
}
if (typeof window.EventSource !== 'function') return;
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('resourcePath', path);
const eventSource = new EventSource(url.toString());
entry.resourceWatchEventSource = eventSource;
eventSource.addEventListener('ready', () => {
if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true');
});
eventSource.addEventListener('change', async () => {
await refreshPassiveResourceEntryFromWatch(entry, rootUri, path);
});
};
const syncActivePassiveResourceWatch = (paneRole, activeResource) => {
const activeEntry = activeResource ? resourceTabRegistry.get(activeResource) : null;
if (!activeEntry) {
resourceTabRegistry.forEach((entry) => {
if (normalizePaneRole(entry?.paneRole) === normalizePaneRole(paneRole)) closePassiveResourceWatch(entry);
});
return;
}
installPassiveResourceWatch(activeEntry);
};
const releaseInlinePdfResource = (entry) => {
if (!entry) return;
const pdf = entry.inlinePdfDocument;
entry.inlinePdfDocument = null;
entry.inlinePdfRenderToken = null;
if (!pdf) return;
try {
void pdf.destroy();
} catch (_) {}
};
const pdfFileUrlFromPreviewHref = (href) => {
const value = String(href || '').trim();
if (!value) return '';
try {
const url = new URL(value, window.location.origin);
return String(url.searchParams.get('fileUrl') || value).trim();
} catch (_) {
return value;
}
};
const renderInlinePdfPage = async (entry, viewer, pdf, pageNumber, evidenceLocator) => {
const page = await pdf.getPage(pageNumber);
const baseViewport = page.getViewport({ scale: 1 });
const availableWidth = Math.max(280, viewer.clientWidth - 20);
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
const viewport = page.getViewport({ scale });
const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
const canvas = document.createElement('canvas');
canvas.className = 'mnote-pdf-page';
canvas.setAttribute('data-page-number', String(pageNumber));
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.display = 'block';
canvas.style.maxWidth = '100%';
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
canvas.style.margin = '0 auto 14px';
canvas.style.background = '#fff';
canvas.style.border = '1px solid #d8d8d2';
canvas.style.boxShadow = '0 2px 10px rgba(25, 25, 22, .08)';
const context = canvas.getContext('2d', { alpha: false });
if (!context) return;
await page.render({
canvasContext: context,
viewport,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null,
}).promise;
const evidencePage = Number(evidenceLocator?.page || 0);
if (evidencePage === pageNumber) {
canvas.setAttribute('data-mnote-evidence-page', 'true');
const bbox = normalizeEvidenceBBox(evidenceLocator?.bbox);
if (bbox) {
const rect = viewport.convertToViewportRectangle([bbox.x0, bbox.y0, bbox.x1, bbox.y1]);
const x = Math.min(rect[0], rect[2]);
const y = Math.min(rect[1], rect[3]);
const width = Math.max(1, Math.abs(rect[2] - rect[0]));
const height = Math.max(1, Math.abs(rect[3] - rect[1]));
context.save();
context.scale(outputScale, outputScale);
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
context.lineWidth = 2;
context.fillRect(x, y, width, height);
context.strokeRect(x, y, width, height);
context.restore();
}
}
if (entry.inlinePdfDocument === pdf) viewer.append(canvas);
if (evidencePage === pageNumber) window.setTimeout(() => canvas.scrollIntoView({ block: 'center', inline: 'nearest' }), 0);
};
const openInlinePdfResourceTab = async (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
const fileUrl = pdfFileUrlFromPreviewHref(href);
if (!(entry?.panel instanceof HTMLElement) || !fileUrl) return false;
releaseInlinePdfResource(entry);
const renderToken = {};
entry.passiveFrameSrc = href;
entry.inlinePdfSourceHref = href;
entry.inlinePdfRenderToken = renderToken;
entry.lastPassiveInput = { ...input };
entry.panel.replaceChildren();
const viewer = document.createElement('div');
viewer.className = 'mnote-pdf-viewer';
viewer.setAttribute('data-mnote-inline-pdf-viewer', 'true');
viewer.style.width = '100%';
viewer.style.maxWidth = '1180px';
viewer.style.margin = '0 auto';
viewer.style.padding = '8px 12px 28px';
entry.panel.append(viewer);
const pdfjsLib = await import('/api/pdfjs/pdf.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = '/api/pdfjs/pdf.worker.mjs';
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(window.location.origin);
const pdf = await pdfjsLib.getDocument({ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }).promise;
entry.inlinePdfDocument = pdf;
const total = Number(pdf.numPages || 0);
viewer.setAttribute('data-mnote-pdf-status', `0 / ${total}`);
const evidenceLocator = normalizeEvidenceLocatorInput(input);
for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {
if (entry.inlinePdfDocument !== pdf || entry.inlinePdfRenderToken !== renderToken) return true;
await renderInlinePdfPage(entry, viewer, pdf, pageNumber, evidenceLocator);
viewer.setAttribute('data-mnote-pdf-status', `${pageNumber} / ${total}`);
}
installPassiveResourceWatch(entry);
return true;
};
const isKnowledgeRagSourceEntry = (entry) => {
if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
return entry.kind === 'image' || entry.kind === 'pdf';
};
const knowledgeRagProvider = () => {
return 'lightrag';
};
const setKnowledgeRagStatus = (entry, status, message, job) => {
if (!(entry?.panel instanceof HTMLElement)) return;
const normalizedStatus = String(status || '').trim() || 'unknown';
entry.knowledgeRagJob = job && typeof job === 'object' ? job : entry.knowledgeRagJob || null;
entry.panel.setAttribute('data-mnote-knowledge-rag-status', normalizedStatus);
if (entry.knowledgeRagJob?.artifactRootRelativePath) {
entry.panel.setAttribute('data-mnote-knowledge-rag-path', String(entry.knowledgeRagJob.artifactRootRelativePath));
}
const statusNode = entry.panel.querySelector('[data-mnote-knowledge-rag-status-text]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = message || (
normalizedStatus === 'done' ? '资料库已索引'
: normalizedStatus === 'running' ? '资料库索引中'
: normalizedStatus === 'failed' ? '资料库索引失败'
: normalizedStatus === 'stale' ? '资料库需更新'
: '资料库未索引'
);
}
const openButton = entry.panel.querySelector('[data-mnote-knowledge-rag-action="open"]');
if (openButton instanceof HTMLButtonElement) openButton.disabled = true;
const insertButton = entry.panel.querySelector('[data-mnote-knowledge-rag-action="insert"]');
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = true;
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-job-updated', {
detail: {
status: normalizedStatus,
job: entry.knowledgeRagJob || null,
rootUri: entry.rootUri || '',
sourceRootRelativePath: entry.path || '',
},
}));
};
const knowledgeRagJobFromRegistryEntry = (registryEntry, rootUri) => {
if (!registryEntry || typeof registryEntry !== 'object') return null;
const sourceRootRelativePath = String(registryEntry.sourceRootRelativePath || registryEntry.source_path || '').trim();
if (!sourceRootRelativePath) return null;
const indexed = Boolean(registryEntry.indexedAtMs || registryEntry.indexed_at_ms) && Boolean(registryEntry.lightRagDocId || registryEntry.light_rag_doc_id);
const failed = registryEntry.deletedAtMs != null || registryEntry.deleted_at_ms != null || registryEntry.stale === true;
const status = failed ? 'failed' : indexed ? 'done' : 'running';
return {
taskKind: 'knowledge_rag',
jobId: String(registryEntry.sourceId || registryEntry.source_id || `knowledge-rag:${sourceRootRelativePath}`),
sourceRootRelativePath,
rootUri: String(registryEntry.rootUri || rootUri || '').trim(),
artifactRootRelativePath: '',
provider: 'lightrag',
status,
stageLabel: status === 'done' ? '资料库已索引' : status === 'failed' ? '资料库需重建' : '资料库索引中',
stale: registryEntry.stale === true,
updatedAtMs: Number(registryEntry.updatedAtMs || registryEntry.updated_at_ms || Date.now()),
finishedAtMs: status === 'done' ? Number(registryEntry.indexedAtMs || registryEntry.indexed_at_ms || Date.now()) : null,
error: status === 'failed' ? 'knowledge_rag_source_stale_or_deleted' : '',
lightRagDocId: String(registryEntry.lightRagDocId || registryEntry.light_rag_doc_id || ''),
lightRagFilePath: String(registryEntry.lightRagFilePath || registryEntry.light_rag_file_path || ''),
};
};
const knowledgeRagRegistryEntries = (payload) => {
const registry = payload && payload.registry && typeof payload.registry === 'object' ? payload.registry : null;
return Array.isArray(registry?.entries) ? registry.entries : [];
};
const readKnowledgeRagStatus = async (entry) => {
if (!isKnowledgeRagSourceEntry(entry)) return null;
const url = new URL('/api/knowledge-rag/status', window.location.origin);
url.searchParams.set('rootUri', entry.rootUri);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return null;
const targetPath = String(entry.path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => {
return String(candidate?.sourceRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '') === targetPath;
});
return knowledgeRagJobFromRegistryEntry(registryEntry, entry.rootUri);
};
const knowledgeRagTaskState = {
rootUri: '',
jobsBySource: new Map(),
drawerOpen: false,
taskFilter: 'active',
eventSource: null,
};
const knowledgeRagTaskKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim();
const updateKnowledgeRagTaskState = (job) => {
const key = knowledgeRagTaskKey(job);
if (!key) return;
if (String(job?.status || '').trim() === 'deleted') {
knowledgeRagTaskState.jobsBySource.delete(key);
renderKnowledgeRagTaskDock();
return;
}
knowledgeRagTaskState.jobsBySource.set(key, job);
renderKnowledgeRagTaskDock();
};
const bindKnowledgeRagTopbarAction = () => {
const toggle = document.querySelector('[data-testid="mnote-knowledge-rag-task-toggle"]');
if (!(toggle instanceof HTMLButtonElement)) return null;
if (toggle.getAttribute('data-mnote-knowledge-rag-bound') === 'true') return toggle;
toggle.setAttribute('data-mnote-knowledge-rag-bound', 'true');
toggle.addEventListener('click', (event) => {
if (toggle.getAttribute('data-mnote-action') === 'open-knowledge-rag-settings') {
event.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
return;
}
void runManualKnowledgeRagForActiveTarget(toggle).catch((error) => {
console.warn('mnote Knowledge RAG 手动入口失败', error);
});
});
return toggle;
};
const knowledgeRagTaskSnapshotFromEntry = (entry, status, provider, message, timestamp = Date.now()) => {
const sourceRootRelativePath = String(entry?.path || '').trim();
return {
jobId: `knowledge-rag-${status}:${sourceRootRelativePath || 'unknown'}:${timestamp}`,
ownerDocumentId: String(entry?.ownerDocumentId || entry?.documentId || currentWebShellDocumentId() || '').trim(),
sourceRootRelativePath,
rootUri: String(entry?.rootUri || knowledgeRagTaskState.rootUri || '').trim(),
artifactRootRelativePath: '',
provider: String(provider || knowledgeRagProvider()),
status,
stageLabel: message || statusTextForKnowledgeRagTask({ status }),
stale: false,
updatedAtMs: timestamp,
finishedAtMs: status === 'failed' ? timestamp : null,
error: status === 'failed' ? String(message || '') : '',
};
};
const statusTextForKnowledgeRagTask = (job) => {
const status = String(job?.status || '').trim();
if (job?.stageLabel) return String(job.stageLabel);
if (job?.taskKind === 'local_index' || job?.taskKind === 'knowledge_rag') {
return status === 'done' ? '索引已完成'
: status === 'failed' ? '索引失败'
: status === 'running' ? '索引中'
: status || '未知';
}
return status === 'done' ? '已完成'
: status === 'failed' ? '任务失败'
: status === 'stale' ? '来源已变化'
: status === 'running' ? '处理中'
: status || '未知';
};
const knowledgeRagDisplayStatus = (job, fallback = 'done') => {
const status = String(job?.status || fallback).trim() || fallback;
return job?.stale === true && status === 'done' ? 'stale' : status;
};
const knowledgeRagTaskCategory = (job) => {
const status = String(job?.status || '').trim();
if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention';
if (['done', 'succeeded', 'success'].includes(status)) return 'completed';
return 'active';
};
const knowledgeRagTaskProgress = (job) => {
const current = Number(job?.progressCurrent ?? job?.currentProgress);
const total = Number(job?.progressTotal ?? job?.maxProgress);
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
return Math.max(0, Math.min(100, Math.round((current / total) * 100)));
}
return null;
};
const knowledgeRagTaskFilterLabel = (filter) => {
return filter === 'active' ? '进行中'
: filter === 'completed' ? '已完成'
: filter === 'attention' ? '需处理'
: '全部';
};
const ensureKnowledgeRagTaskDock = () => {
let dock = document.querySelector('[data-testid="mnote-knowledge-rag-task-dock"]');
if (!(dock instanceof HTMLElement)) {
dock = document.createElement('section');
dock.className = 'mnote-knowledge-rag-task-dock';
dock.setAttribute('data-testid', 'mnote-knowledge-rag-task-dock');
dock.innerHTML = '<div class="mnote-knowledge-rag-task-drawer" data-testid="mnote-knowledge-rag-task-drawer" hidden><div class="mnote-knowledge-rag-task-panel"><div class="mnote-knowledge-rag-task-head"><div><strong>后台任务</strong><span data-mnote-knowledge-rag-task-summary>暂无任务</span></div><button type="button" class="mnote-knowledge-rag-task-close" data-mnote-knowledge-rag-task-close aria-label="收起后台任务">×</button></div><div class="mnote-knowledge-rag-task-tabs" data-mnote-knowledge-rag-task-tabs></div><div class="mnote-knowledge-rag-task-toolbar"><span data-mnote-knowledge-rag-task-filter-label>进行中</span><button type="button" data-mnote-knowledge-rag-task-clear-completed>清除已完成</button></div><div class="mnote-knowledge-rag-task-list" data-testid="mnote-knowledge-rag-task-list"></div></div></div>';
document.body.appendChild(dock);
}
let toggle = document.querySelector('[data-testid="mnote-knowledge-rag-task-toggle"]');
if (toggle instanceof HTMLButtonElement) bindKnowledgeRagTopbarAction();
if (dock.getAttribute('data-mnote-knowledge-rag-bound') === 'true') return dock;
dock.setAttribute('data-mnote-knowledge-rag-bound', 'true');
dock.addEventListener('click', (event) => {
const closeButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-close]') : null;
if (closeButton instanceof HTMLElement) {
knowledgeRagTaskState.drawerOpen = false;
renderKnowledgeRagTaskDock();
return;
}
const clearButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-clear]') : null;
if (clearButton instanceof HTMLElement) {
const sourcePath = clearButton.getAttribute('data-mnote-knowledge-rag-task-clear') || '';
if (sourcePath) {
knowledgeRagTaskState.jobsBySource.delete(sourcePath);
renderKnowledgeRagTaskDock();
}
return;
}
const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-tab]') : null;
if (tabButton instanceof HTMLElement) {
knowledgeRagTaskState.taskFilter = tabButton.getAttribute('data-mnote-knowledge-rag-task-tab') || 'active';
renderKnowledgeRagTaskDock();
return;
}
const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-clear-completed]') : null;
if (clearCompleted instanceof HTMLElement) {
Array.from(knowledgeRagTaskState.jobsBySource.entries()).forEach(([key, job]) => {
if (knowledgeRagTaskCategory(job) === 'completed') knowledgeRagTaskState.jobsBySource.delete(key);
});
renderKnowledgeRagTaskDock();
return;
}
const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-delete]') : null;
if (deleteButton instanceof HTMLElement) {
const sourcePath = deleteButton.getAttribute('data-mnote-knowledge-rag-task-delete') || '';
if (sourcePath) {
void deleteKnowledgeRagSourceTask(sourcePath).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
document.documentElement.setAttribute('data-mnote-knowledge-rag-delete-error', message);
console.warn('mnote Knowledge RAG 删除失败', error);
});
}
return;
}
const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-open]') : null;
if (openButton instanceof HTMLElement) {
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
}
const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-knowledge-rag-task-retry]') : null;
if (retryButton instanceof HTMLElement) {
const sourcePath = retryButton.getAttribute('data-mnote-knowledge-rag-task-retry') || '';
const job = knowledgeRagTaskState.jobsBySource.get(sourcePath) || null;
if (job) {
const entry = {
sourceKind: 'local_folder',
rootUri: knowledgeRagTaskState.rootUri,
path: sourcePath,
kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
title: sourcePath.split('/').filter(Boolean).pop() || sourcePath,
documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
workspaceId: currentWebShellWorkspaceId() || '',
knowledgeRagJob: job,
};
void createKnowledgeRagIngestTask(entry).catch((error) => console.warn('mnote Knowledge RAG 任务重试失败', error));
}
}
});
return dock;
};
const renderKnowledgeRagTaskDock = () => {
const dock = ensureKnowledgeRagTaskDock();
const jobs = Array.from(knowledgeRagTaskState.jobsBySource.values())
.sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
const activeJobs = jobs.filter((job) => knowledgeRagTaskCategory(job) === 'active');
const attentionJobs = jobs.filter((job) => knowledgeRagTaskCategory(job) === 'attention');
const completedJobs = jobs.filter((job) => knowledgeRagTaskCategory(job) === 'completed');
const runningCount = activeJobs.length;
const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-knowledge-rag-task-toggle"], [data-testid="mnote-floating-task-toggle"]'))
.filter((node) => node instanceof HTMLButtonElement);
taskToggles.forEach((toggle) => {
const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-knowledge-rag-settings';
const label = runningCount > 0 ? `${runningCount} 个资料库索引处理中,打开资料库设置` : (jobs.length > 0 ? `${jobs.length} 个资料库任务,打开资料库设置` : '资料库设置');
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
toggle.setAttribute('title', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-expanded', knowledgeRagTaskState.drawerOpen ? 'true' : 'false');
toggle.classList.toggle('has-mnote-knowledge-rag-tasks', jobs.length > 0);
const badge = toggle.querySelector('[data-mnote-knowledge-rag-task-count]');
if (badge instanceof HTMLElement) {
badge.textContent = String(runningCount > 0 ? runningCount : jobs.length);
badge.hidden = jobs.length === 0;
} else {
toggle.textContent = runningCount > 0 ? `索引 ${runningCount} 处理中` : `索引 ${jobs.length}`;
}
});
const drawer = dock.querySelector('[data-testid="mnote-knowledge-rag-task-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = !knowledgeRagTaskState.drawerOpen;
const summary = dock.querySelector('[data-mnote-knowledge-rag-task-summary]');
if (summary instanceof HTMLElement) {
summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`;
}
const tabs = dock.querySelector('[data-mnote-knowledge-rag-task-tabs]');
if (tabs instanceof HTMLElement) {
const tabItems = [
['active', '进行中', activeJobs.length],
['attention', '需处理', attentionJobs.length],
['completed', '已完成', completedJobs.length],
['all', '全部', jobs.length],
];
tabs.replaceChildren();
tabItems.forEach(([key, label, count]) => {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('data-mnote-knowledge-rag-task-tab', key);
button.setAttribute('aria-selected', knowledgeRagTaskState.taskFilter === key ? 'true' : 'false');
button.textContent = `${label} ${count}`;
tabs.appendChild(button);
});
}
const filterLabel = dock.querySelector('[data-mnote-knowledge-rag-task-filter-label]');
if (filterLabel instanceof HTMLElement) filterLabel.textContent = knowledgeRagTaskFilterLabel(knowledgeRagTaskState.taskFilter);
const clearCompleted = dock.querySelector('[data-mnote-knowledge-rag-task-clear-completed]');
if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0;
const list = dock.querySelector('[data-testid="mnote-knowledge-rag-task-list"]');
if (!(list instanceof HTMLElement)) return;
list.replaceChildren();
const visibleJobs = jobs.filter((job) => {
return knowledgeRagTaskState.taskFilter === 'all' || knowledgeRagTaskCategory(job) === knowledgeRagTaskState.taskFilter;
});
if (!visibleJobs.length) {
const empty = document.createElement('div');
empty.className = 'mnote-knowledge-rag-task-empty';
empty.textContent = jobs.length ? `暂无${knowledgeRagTaskFilterLabel(knowledgeRagTaskState.taskFilter)}任务` : '暂无后台任务';
list.appendChild(empty);
return;
}
visibleJobs.forEach((job) => {
const row = document.createElement('div');
row.className = 'mnote-knowledge-rag-task-row';
row.setAttribute('data-mnote-knowledge-rag-task-row', String(job.sourceRootRelativePath || ''));
row.setAttribute('data-mnote-knowledge-rag-task-status', String(job.status || ''));
row.setAttribute('data-mnote-knowledge-rag-task-category', knowledgeRagTaskCategory(job));
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '资料库索引';
const category = knowledgeRagTaskCategory(job);
const progress = knowledgeRagTaskProgress(job);
row.innerHTML = '<div class="mnote-knowledge-rag-task-main"><div class="mnote-knowledge-rag-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-knowledge-rag-task-progress" role="progressbar"><i></i></div></div><div class="mnote-knowledge-rag-task-actions"><button type="button" data-mnote-knowledge-rag-task-open>打开</button><button type="button" data-mnote-knowledge-rag-task-retry>重试</button><button type="button" data-mnote-knowledge-rag-task-clear>清除</button><button type="button" data-mnote-knowledge-rag-task-delete>删除</button></div>';
const titleNode = row.querySelector('strong');
if (titleNode instanceof HTMLElement) {
titleNode.textContent = title;
titleNode.setAttribute('title', String(job.sourceRootRelativePath || title));
}
const categoryNode = row.querySelector('em');
if (categoryNode instanceof HTMLElement) {
categoryNode.textContent = category === 'active' ? '进行中' : category === 'attention' ? '需处理' : '已完成';
}
const statusNode = row.querySelector('span');
if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForKnowledgeRagTask(job);
const progressBar = row.querySelector('.mnote-knowledge-rag-task-progress');
const progressValue = row.querySelector('.mnote-knowledge-rag-task-progress i');
if (progressBar instanceof HTMLElement && progressValue instanceof HTMLElement) {
progressBar.hidden = category !== 'active' && progress === null;
progressBar.setAttribute('aria-valuemin', '0');
progressBar.setAttribute('aria-valuemax', '100');
if (progress === null) {
progressBar.setAttribute('data-progress-mode', 'indeterminate');
progressBar.removeAttribute('aria-valuenow');
progressValue.style.width = '';
} else {
progressBar.setAttribute('data-progress-mode', 'determinate');
progressBar.setAttribute('aria-valuenow', String(progress));
progressValue.style.width = `${progress}%`;
}
}
const open = row.querySelector('[data-mnote-knowledge-rag-task-open]');
if (open instanceof HTMLButtonElement) {
open.setAttribute('data-mnote-knowledge-rag-task-open', String(job.sourceRootRelativePath || ''));
open.disabled = !job.artifactRootRelativePath;
open.hidden = job.taskKind === 'local_index' || job.taskKind === 'knowledge_rag';
}
const retry = row.querySelector('[data-mnote-knowledge-rag-task-retry]');
if (retry instanceof HTMLButtonElement) {
retry.setAttribute('data-mnote-knowledge-rag-task-retry', String(job.sourceRootRelativePath || ''));
retry.hidden = job.taskKind === 'local_index' || !['failed', 'stale'].includes(String(job.status || ''));
}
const clear = row.querySelector('[data-mnote-knowledge-rag-task-clear]');
if (clear instanceof HTMLButtonElement) {
clear.setAttribute('data-mnote-knowledge-rag-task-clear', String(job.sourceRootRelativePath || ''));
}
const deleteSource = row.querySelector('[data-mnote-knowledge-rag-task-delete]');
if (deleteSource instanceof HTMLButtonElement) {
deleteSource.setAttribute('data-mnote-knowledge-rag-task-delete', String(job.sourceRootRelativePath || ''));
deleteSource.hidden = job.taskKind === 'local_index' || !String(job.sourceRootRelativePath || '').trim();
}
list.appendChild(row);
});
};
const loadKnowledgeRagTasks = async (rootUri) => {
const normalizedRoot = String(rootUri || '').trim();
if (!normalizedRoot) return;
knowledgeRagTaskState.rootUri = normalizedRoot;
const url = new URL('/api/knowledge-rag/status', window.location.origin);
url.searchParams.set('rootUri', normalizedRoot);
const workspaceId = String(currentWebShellWorkspaceId() || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return;
knowledgeRagRegistryEntries(payload).forEach((entry) => {
const job = knowledgeRagJobFromRegistryEntry(entry, normalizedRoot);
updateKnowledgeRagTaskState(job);
});
renderKnowledgeRagTaskDock();
};
const markKnowledgeRagTaskEventStreamRetired = (rootUri) => {
const normalizedRoot = String(rootUri || '').trim();
if (!normalizedRoot) return;
if (knowledgeRagTaskState.eventSource) {
try { knowledgeRagTaskState.eventSource.close(); } catch (_) {}
knowledgeRagTaskState.eventSource = null;
}
knowledgeRagTaskState.rootUri = normalizedRoot;
document.documentElement.setAttribute('data-mnote-local-ocr-event-stream-retired', 'true');
};
const openKnowledgeRagSourcePanel = async (entry, job) => {
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
return false;
};
const insertRetiredOcrLink = async (entry, job) => {
throw new Error('OCR sidecar 已退役,请使用 LightRAG 资料库引用与问答。');
};
const createKnowledgeRagIngestTask = async (entry) => {
if (!isKnowledgeRagSourceEntry(entry)) return null;
const entryRootUri = String(entry.rootUri || '').trim();
if (entryRootUri) knowledgeRagTaskState.rootUri = entryRootUri;
const startedAt = Date.now();
const pendingJob = knowledgeRagTaskSnapshotFromEntry(entry, 'running', 'lightrag', '资料库索引中', startedAt);
pendingJob.taskKind = 'knowledge_rag';
setKnowledgeRagStatus(entry, 'running', '资料库索引中', pendingJob);
updateKnowledgeRagTaskState(pendingJob);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
const body = {
workspaceId,
rootUri: String(entry.rootUri || '').trim(),
sources: [{ sourcePath: String(entry.path || '').trim() }],
};
const response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
const message = payload?.error?.message || payload?.message || payload?.error?.code || `knowledge_rag_ingest_failed_${response.status}`;
const failedJob = {
...pendingJob,
status: 'failed',
stageLabel: message,
updatedAtMs: Date.now(),
finishedAtMs: Date.now(),
error: message,
};
setKnowledgeRagStatus(entry, 'failed', message, failedJob);
updateKnowledgeRagTaskState(failedJob);
throw new Error(message);
}
const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => {
return String(candidate?.sourceRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '') === String(entry.path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
});
const job = knowledgeRagJobFromRegistryEntry(registryEntry, entryRootUri) || {
...pendingJob,
status: payload.retryRequired ? 'running' : 'done',
stageLabel: payload.retryRequired ? 'LightRAG 忙碌,等待重试' : '资料库已提交索引',
updatedAtMs: Date.now(),
finishedAtMs: payload.retryRequired ? null : Date.now(),
};
const displayStatus = knowledgeRagDisplayStatus(job, 'done');
setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job);
if (job) updateKnowledgeRagTaskState(job);
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri: entryRootUri, workspaceId, sourceRootRelativePath: String(entry.path || '').trim(), result: payload }
}));
return job;
};
const deleteKnowledgeRagSourceTask = async (sourceRootRelativePath) => {
const sourcePath = String(sourceRootRelativePath || '').trim();
if (!sourcePath) return false;
const job = knowledgeRagTaskState.jobsBySource.get(sourcePath) || null;
const rootUri = String(job?.rootUri || knowledgeRagTaskState.rootUri || currentWebShellRootUri() || '').trim();
const response = await fetch('/api/knowledge-rag/delete-source', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri,
workspaceId: String(currentWebShellWorkspaceId() || '').trim(),
sourcePath,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `knowledge_rag_delete_failed_${response.status}`);
}
knowledgeRagTaskState.jobsBySource.delete(sourcePath);
renderKnowledgeRagTaskDock();
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri, workspaceId: String(currentWebShellWorkspaceId() || '').trim(), sourceRootRelativePath: sourcePath, result: payload }
}));
return true;
};
const activeResourceTabEntry = (paneRole = 'primary') => {
const role = normalizePaneRole(paneRole);
for (const entry of resourceTabRegistry.values()) {
if (normalizePaneRole(entry?.paneRole) !== role) continue;
if (entry?.tab instanceof HTMLElement && entry.tab.getAttribute('aria-selected') === 'true') return entry;
}
return null;
};
const knowledgeRagCandidatesFromActiveMarkdown = (paneRole = 'primary') => {
const role = normalizePaneRole(paneRole);
const sourceKind = currentWebShellSourceKind();
const rootUri = currentWebShellRootUri();
if (sourceKind !== 'local_folder' || !rootUri) return [];
const documentId = documentIdForPane(role) || currentWebShellDocumentId();
if (!documentId) return [];
const workspaceId = currentWebShellWorkspaceId();
const seen = new Set();
const candidates = [];
document.querySelectorAll(`.document-pane[data-pane-role="${role}"] .ProseMirror img`).forEach((image) => {
if (!(image instanceof HTMLImageElement)) return;
const sourcePath = localFileOpenPathFromTiptapHref(image.getAttribute('src') || image.src || '');
if (!sourcePath || seen.has(sourcePath)) return;
seen.add(sourcePath);
const title = sourcePath.split('/').filter(Boolean).pop() || sourcePath;
candidates.push({
sourceKind: 'local_folder',
rootUri,
path: sourcePath,
kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
title,
fileName: title,
documentId,
ownerDocumentId: documentId,
workspaceId,
objectIdentity: `local-file:${sourcePath}`,
assetId: `local-file:${sourcePath}`,
});
});
return candidates;
};
const knowledgeRagCandidatesForActiveTarget = (paneRole = 'primary') => {
const activeResource = activeResourceTabEntry(paneRole);
if (isKnowledgeRagSourceEntry(activeResource)) return [activeResource];
return knowledgeRagCandidatesFromActiveMarkdown(paneRole);
};
const runManualKnowledgeRagForActiveTarget = async (toggle) => {
ensureKnowledgeRagTaskDock();
const candidates = knowledgeRagCandidatesForActiveTarget('primary');
if (!candidates.length) {
if (knowledgeRagTaskState.jobsBySource.size > 0) {
knowledgeRagTaskState.drawerOpen = !knowledgeRagTaskState.drawerOpen;
renderKnowledgeRagTaskDock();
}
return [];
}
if (toggle instanceof HTMLButtonElement) toggle.disabled = true;
const jobs = [];
let createdCount = 0;
try {
for (const entry of candidates) {
try {
const existing = await readKnowledgeRagStatus(entry);
const existingStatus = String(existing?.status || '').trim();
if (existing && !existing.stale && ['done', 'running'].includes(existingStatus)) {
updateKnowledgeRagTaskState(existing);
jobs.push(existing);
continue;
}
const job = await createKnowledgeRagIngestTask(entry);
if (job) {
createdCount += 1;
jobs.push(job);
}
} catch (error) {
console.warn('mnote Knowledge RAG 手动任务失败', entry?.path, error);
if (knowledgeRagTaskState.jobsBySource.has(String(entry?.path || '').trim())) {
createdCount += 1;
}
}
}
if (createdCount === 0 && knowledgeRagTaskState.jobsBySource.size > 0) {
knowledgeRagTaskState.drawerOpen = !knowledgeRagTaskState.drawerOpen;
}
} finally {
if (toggle instanceof HTMLButtonElement) toggle.disabled = false;
renderKnowledgeRagTaskDock();
}
return jobs;
};
window.addEventListener('mnote:knowledge-rag-settings-action', (event) => {
const action = String(event?.detail?.action || '').trim();
if (action === 'run-active') {
const toggle = document.querySelector('[data-testid="mnote-knowledge-rag-task-toggle"]');
void runManualKnowledgeRagForActiveTarget(toggle instanceof HTMLButtonElement ? toggle : null).catch((error) => {
console.warn('mnote Knowledge RAG 设置入口识别失败', error);
});
return;
}
if (action === 'tasks') {
ensureKnowledgeRagTaskDock();
knowledgeRagTaskState.drawerOpen = true;
renderKnowledgeRagTaskDock();
}
});
window.addEventListener('mnote:local-background-task-updated', (event) => {
const task = event?.detail?.task && typeof event.detail.task === 'object' ? event.detail.task : null;
if (!task) return;
updateKnowledgeRagTaskState({
taskKind: String(task.taskKind || 'local_index'),
jobId: String(task.jobId || task.taskId || `local-index-${Date.now()}`),
sourceRootRelativePath: String(task.sourceRootRelativePath || task.taskId || 'local-index'),
rootUri: String(task.rootUri || knowledgeRagTaskState.rootUri || currentWebShellRootUri() || '').trim(),
artifactRootRelativePath: '',
provider: String(task.provider || 'mnote-web'),
status: String(task.status || 'running'),
stageLabel: String(task.stageLabel || ''),
stale: false,
updatedAtMs: Number(task.updatedAtMs || Date.now()),
finishedAtMs: task.finishedAtMs == null ? null : Number(task.finishedAtMs),
error: task.error || null,
});
});
const renderKnowledgeRagToolbar = (entry) => {
if (!isKnowledgeRagSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
const toolbar = entry.panel.querySelector('[data-testid="mnote-knowledge-rag-toolbar"]');
if (!(toolbar instanceof HTMLElement)) return;
const runButton = toolbar.querySelector('[data-mnote-knowledge-rag-action="run"]');
const openButton = toolbar.querySelector('[data-mnote-knowledge-rag-action="open"]');
const insertButton = toolbar.querySelector('[data-mnote-knowledge-rag-action="insert"]');
if (runButton instanceof HTMLButtonElement) {
runButton.addEventListener('click', async () => {
runButton.disabled = true;
try {
await createKnowledgeRagIngestTask(entry);
} catch (error) {
console.warn('mnote Knowledge RAG 生成失败', error);
} finally {
runButton.disabled = false;
}
});
}
if (openButton instanceof HTMLButtonElement) {
openButton.addEventListener('click', () => {
void openKnowledgeRagSourcePanel(entry, entry.knowledgeRagJob).catch((error) => {
console.warn('mnote Knowledge RAG 打开失败', error);
});
});
}
if (insertButton instanceof HTMLButtonElement) {
insertButton.addEventListener('click', () => {
void insertRetiredOcrLink(entry, entry.knowledgeRagJob).catch((error) => {
console.warn('mnote Knowledge RAG 插入失败', error);
setKnowledgeRagStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.knowledgeRagJob || null);
});
});
}
setKnowledgeRagStatus(entry, 'idle', '资料库未索引', null);
ensureKnowledgeRagTaskDock();
void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined);
void readKnowledgeRagStatus(entry).then((job) => {
if (!job) return;
const displayStatus = knowledgeRagDisplayStatus(job, 'done');
setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job);
updateKnowledgeRagTaskState(job);
}).catch(() => undefined);
};
const createResourceSession = (entry, input, readResult) => {
const resourcePath = String(input.path || '');
const resourceDocumentId = localMarkdownDocumentIdFromRelativePath(resourcePath) || entry.objectIdentity;
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: resourceDocumentId,
ownerDocumentId: String(input.documentId || currentWebShellDocumentId() || '').trim(),
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
sourceKind: 'local_folder',
rootUri: String(input.rootUri || ''),
resourcePath,
saveEndpoint: '/api/local-folder/resource/write',
pageAggregateScriptId: '',
latestAggregate: null,
title: entry.title,
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
revision: null,
conflictDetectionKey,
fileVersion: conflictDetectionKey,
lastExternalConflictDetectionKey: conflictDetectionKey,
readOnly: false,
lastActiveAt: 0,
dirty: false,
saving: false,
hasExternalConflict: false,
externalChangePending: false,
externalRefreshSource: '',
lastExternalChangeSignalAt: 0,
lastSelfSaveSignalAt: 0,
lastExternalWriteSource: '',
lastExternalWriteRunId: '',
lastUserInputAt: 0,
saveTimer: 0,
externalRefreshTimer: 0,
releaseTimer: 0,
views: new Map(),
localFolderChannel: null,
status: 'ready',
error: null,
};
documentSessionRegistry.set(session.key, session);
if (typeof ensureLocalFolderEventChannel === 'function') {
ensureLocalFolderEventChannel(session);
}
return session;
};
const openTiptapResourceTab = async (entry, input) => {
const runtime = await loadRuntime();
const paneRole = normalizePaneRole(entry.paneRole);
entry.panel.innerHTML = `<main class="document-shell mnote-resource-tab-text-shell" data-editor-host="leptos_tiptap_resource" data-mnote-editor-kind="resource" data-pane-role="${paneRole}"><div class="mnote-resource-tab-editor-root" data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_resource" data-mnote-editor-kind="resource" data-runtime-editor-status="booting" data-pane-role="${paneRole}"></div><div class="sr-only" data-editor-host-observability="rust-web-resource-tab" data-mnote-editor-kind="resource" data-pane-role="${paneRole}"></div></main>`;
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = entry.panel.querySelector('[data-editor-host-observability]');
if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing');
const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`);
const readResult = payload.result || {};
const session = createResourceSession(entry, input, readResult);
const runtimeDescriptor = {
paneRole,
root,
observability,
aggregate: { layout: { pageOptions: {} } },
bootstrap: {
documentId: String(input.documentId || currentWebShellDocumentId() || '').trim(),
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
sourceKind: 'local_folder',
rootUri: String(input.rootUri || ''),
saveEndpoint: '/api/local-folder/resource/write',
},
};
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
const mountId = runtime.mount(root, {
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: false,
editable: true,
pageOptions: {},
});
view.mountId = mountId;
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource');
root.setAttribute('data-document-id', session.ownerDocumentId || '');
root.setAttribute('data-workspace-id', session.workspaceId || '');
if (entry.panel instanceof HTMLElement) {
const shell = entry.panel.querySelector('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-document-id', session.ownerDocumentId || '');
shell.setAttribute('data-workspace-id', session.workspaceId || '');
}
}
setStatus(runtimeDescriptor, 'mounting-editor');
entry.view = view;
entry.session = session;
markIntendedSlashRoot(entry);
};
const openReadonlyCodeResourceTab = async (entry, input) => {
entry.panel.innerHTML = '';
const shell = document.createElement('div');
shell.className = 'mnote-resource-tab-code-shell';
shell.setAttribute('data-mnote-resource-readonly-preview', 'true');
const header = document.createElement('div');
header.className = 'mnote-resource-tab-code-header';
const title = document.createElement('div');
title.className = 'mnote-resource-tab-code-title';
title.textContent = entry.title;
const meta = document.createElement('div');
meta.className = 'mnote-resource-tab-code-meta';
meta.textContent = '只读预览';
header.append(title, meta);
const pre = document.createElement('pre');
pre.className = 'mnote-resource-tab-code-preview';
const code = document.createElement('code');
pre.append(code);
shell.append(header, pre);
entry.panel.append(shell);
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 rawText = typeof readResult.text === 'string' ? readResult.text : '';
const truncated = rawText.length > readonlyCodePreviewMaxChars;
code.textContent = truncated
? `${rawText.slice(0, readonlyCodePreviewMaxChars)}\n\n/* 预览已截断,文件过大,请用外部编辑器查看完整内容。 */`
: rawText;
const fileSize = new Blob([rawText]).size;
meta.textContent = truncated
? `只读预览 · 已截断 · ${fileSize.toLocaleString()} bytes`
: `只读预览 · ${fileSize.toLocaleString()} bytes`;
entry.session = null;
entry.view = null;
};
const ensureOnlyofficeBridgeReadyListener = () => {
if (onlyofficeBridgeReadyListenerBound) return;
onlyofficeBridgeReadyListenerBound = true;
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
const detail = event.data && typeof event.data === 'object' ? event.data : null;
if (!detail || detail.type !== 'mnote:onlyoffice-bridge-ready') return;
syncOpenEditorsSnapshot();
});
};
const openPassiveResourceTab = async (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
if (entry.kind === 'image') {
entry.panel.innerHTML = '<div class="mnote-resource-tab-image-shell"><img class="mnote-resource-tab-image" alt=""><div class="mnote-resource-tab-bbox-highlight" data-mnote-evidence-bbox-highlight="true" hidden></div></div>';
const img = entry.panel.querySelector('img');
if (img instanceof HTMLImageElement) {
img.src = href;
img.alt = entry.title;
}
applyEvidenceLocatorToEntry(entry, input);
ensureKnowledgeRagTaskDock();
void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined);
installPassiveResourceWatch(entry);
return;
}
if (entry.kind === 'pdf') {
await openInlinePdfResourceTab(entry, input);
applyEvidenceLocatorToEntry(entry, input);
if (isKnowledgeRagSourceEntry(entry)) {
ensureKnowledgeRagTaskDock();
void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined);
}
installPassiveResourceWatch(entry);
return;
}
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
const frame = entry.panel.querySelector('iframe');
if (frame instanceof HTMLIFrameElement) {
frame.title = entry.title;
if (entry.kind === 'office') {
ensureOnlyofficeBridgeReadyListener();
frame.addEventListener('load', () => {
syncOpenEditorsSnapshot();
}, { once: true });
}
frame.src = href;
entry.passiveFrameSrc = href;
}
applyEvidenceLocatorToEntry(entry, input);
if (isKnowledgeRagSourceEntry(entry)) {
ensureKnowledgeRagTaskDock();
void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined);
}
installPassiveResourceWatch(entry);
};
const refreshExistingPdfResourceTab = async (entry, input) => {
if (!entry || entry.kind !== 'pdf') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
if (!nextHref) return false;
if (nextHref !== String(entry.inlinePdfSourceHref || entry.passiveFrameSrc || '').trim()) {
await openPassiveResourceTab(entry, input);
}
return true;
};
const officePreviewBaseHref = (href) => {
const raw = String(href || '').trim();
if (!raw) return '';
try {
const url = new URL(raw, window.location.origin);
['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'evidenceText', 'query', 'searchQuery', 'mnoteResourceReload'].forEach((key) => {
url.searchParams.delete(key);
});
return url.pathname + '?' + url.searchParams.toString();
} catch (_) {
return raw;
}
};
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 (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void 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);
const requestedKind = normalizeResourceTabKind(input);
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) {
activateMainEditorTab(registryKey, paneRole);
await refreshExistingPdfResourceTab(existing, input);
refreshExistingOfficeResourceTab(existing, input);
applyEvidenceLocatorToEntry(existing, input);
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') {
await openTiptapResourceTab(entry, input);
} else if (entry.kind === 'code') {
await openReadonlyCodeResourceTab(entry, input);
} else {
await openPassiveResourceTab(entry, input);
}
applyEvidenceLocatorToEntry(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();
};
try {
bindKnowledgeRagTopbarAction();
} catch (_) {}
return {
activateMainEditorTab,
bindMainEditorPageTab,
buildOpenEditorsSnapshot,
closeResourceTabsForPane,
normalizePaneRole,
openResourceInActiveTab,
resolveResourceOpen,
syncResourceSessionTabGuards,
};
};