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

2495 lines
114 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 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 && !lineRange && !charRange) return null;
return {
schema: 'mnote.evidence_locator.v1',
...(locator || {}),
page: Number.isFinite(page) ? page : null,
bbox,
sourceMapPath,
blockId,
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 (url.pathname === '/office-preview' && frame.contentWindow) {
frame.contentWindow.postMessage({
type: 'mnote:office-evidence-locator',
page: locator.page,
bbox,
sourceMapPath: locator.sourceMapPath || '',
blockId: locator.blockId || '',
}, 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.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 installPassiveResourceWatch = (entry) => {
if (!entry || entry.session || typeof window.EventSource !== 'function') return;
const rootUri = String(entry.rootUri || '').trim();
const path = String(entry.path || '').trim();
if (!rootUri || !path) return;
if (!isActiveResourceTabEntry(entry)) {
closePassiveResourceWatch(entry);
return;
}
closeInactivePassiveResourceWatches(entry);
if (entry.resourceWatchEventSource) {
try {
entry.resourceWatchEventSource.close();
} catch (_) {}
entry.resourceWatchEventSource = null;
}
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('resourcePath', path);
const eventSource = new EventSource(url.toString());
entry.resourceWatchEventSource = eventSource;
eventSource.addEventListener('ready', () => {
if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true');
});
eventSource.addEventListener('change', async () => {
try {
const response = await fetch(localResourceStatUrl(rootUri, path), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists);
if (!exists) {
renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。');
return;
}
reloadPassiveResourceTab(entry);
} catch (error) {
renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error));
}
});
};
const 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 isLocalOcrSourceEntry = (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 localOcrProvider = () => {
const override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
return override === 'mock' ? 'mock' : 'mineru';
};
const localOcrAutoEnabled = async () => {
const cached = window.__MNOTE_LOCAL_OCR_PREFERENCES;
if (cached && typeof cached === 'object' && cached['localOcr.autoEnabled'] === true) return true;
const documentId = currentWebShellDocumentId();
const workspaceId = currentWebShellWorkspaceId();
const sourceKind = currentWebShellSourceKind();
const rootUri = currentWebShellRootUri();
if (!documentId && !workspaceId) return false;
try {
const params = new URLSearchParams();
if (documentId) params.set('documentId', documentId);
if (workspaceId) params.set('workspaceId', workspaceId);
if (sourceKind) params.set('sourceKind', sourceKind);
if (rootUri) params.set('rootUri', rootUri);
const response = await fetch('/api/ui/preferences/effective?' + params.toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return false;
const preferences = payload.result?.localOcrPreferences && typeof payload.result.localOcrPreferences === 'object'
? payload.result.localOcrPreferences
: {};
window.__MNOTE_LOCAL_OCR_PREFERENCES = { 'localOcr.autoEnabled': false, ...preferences };
return window.__MNOTE_LOCAL_OCR_PREFERENCES['localOcr.autoEnabled'] === true;
} catch (_) {
return false;
}
};
const setLocalOcrStatus = (entry, status, message, job) => {
if (!(entry?.panel instanceof HTMLElement)) return;
const normalizedStatus = String(status || '').trim() || 'unknown';
entry.localOcrJob = job && typeof job === 'object' ? job : entry.localOcrJob || null;
entry.panel.setAttribute('data-mnote-local-ocr-status', normalizedStatus);
if (entry.localOcrJob?.ocrRootRelativePath) {
entry.panel.setAttribute('data-mnote-local-ocr-path', String(entry.localOcrJob.ocrRootRelativePath));
}
const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = message || (
normalizedStatus === 'done' ? 'OCR 已完成'
: normalizedStatus === 'running' ? 'OCR 处理中'
: normalizedStatus === 'failed' ? 'OCR 失败'
: normalizedStatus === 'stale' ? 'OCR 需更新'
: 'OCR 未生成'
);
}
const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]');
if (openButton instanceof HTMLButtonElement) openButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]');
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: {
status: normalizedStatus,
job: entry.localOcrJob || null,
rootUri: entry.rootUri || '',
sourceRootRelativePath: entry.path || '',
},
}));
};
const readLocalOcrStatus = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return null;
const url = new URL('/api/local-folder/ocr/status', window.location.origin);
url.searchParams.set('rootUri', entry.rootUri);
url.searchParams.set('sourceRootRelativePath', entry.path);
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;
return payload.job || null;
};
const localOcrTaskState = {
rootUri: '',
jobsBySource: new Map(),
drawerOpen: false,
taskFilter: 'active',
eventSource: null,
fileTreeRefreshKeys: new Set(),
};
const localOcrJobKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim();
const localOcrParentRelativePath = (relativePath) => {
const normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized || normalized.indexOf('/') < 0) return '';
return normalized.split('/').slice(0, -1).join('/');
};
const dispatchLocalOcrFileTreeRefresh = (job, rootUri) => {
const status = String(job?.status || '').trim();
if (!['done', 'stale'].includes(status)) return;
const ocrPath = String(job?.ocrRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
const normalizedRootUri = String(rootUri || localOcrTaskState.rootUri || '').trim();
if (!ocrPath || !normalizedRootUri) return;
const refreshKey = `${ocrPath}:${String(job?.updatedAtMs || job?.finishedAtMs || status)}`;
if (localOcrTaskState.fileTreeRefreshKeys.has(refreshKey)) return;
localOcrTaskState.fileTreeRefreshKeys.add(refreshKey);
const ocrParent = localOcrParentRelativePath(ocrPath);
const ocrParentParent = localOcrParentRelativePath(ocrParent);
const affectedParents = [ocrParent, ocrParentParent]
.filter((path, index, list) => index === list.indexOf(path))
.map((relativePath) => ({ relativePath, reason: 'local-ocr-sidecar-written' }));
const changedPaths = [
{ relativePath: ocrPath, changeType: 'created' },
ocrParent ? { relativePath: ocrParent, changeType: 'created' } : null,
].filter(Boolean);
document.documentElement.setAttribute('data-mnote-local-ocr-filetree-refresh', ocrPath);
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: {
payload: {
schema: 'mnote.local_folder.watch_batch.v1',
source: 'local_ocr.job.updated',
rootUri: normalizedRootUri,
revision: String(job?.updatedAtMs || Date.now()),
changedPaths,
affectedParents,
},
},
}));
};
const updateLocalOcrTaskState = (job) => {
const key = localOcrJobKey(job);
if (!key) return;
if (String(job?.status || '').trim() === 'deleted') {
localOcrTaskState.jobsBySource.delete(key);
renderLocalOcrTaskDock();
return;
}
localOcrTaskState.jobsBySource.set(key, job);
renderLocalOcrTaskDock();
};
const bindLocalOcrTopbarAction = () => {
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
if (!(toggle instanceof HTMLButtonElement)) return null;
if (toggle.getAttribute('data-mnote-local-ocr-bound') === 'true') return toggle;
toggle.setAttribute('data-mnote-local-ocr-bound', 'true');
toggle.addEventListener('click', (event) => {
if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') {
event.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:open-local-ocr-settings'));
return;
}
void runManualLocalOcrForActiveTarget(toggle).catch((error) => {
console.warn('mnote local OCR 手动入口失败', error);
});
});
return toggle;
};
const localOcrJobSnapshotFromEntry = (entry, status, provider, message, timestamp = Date.now()) => {
const sourceRootRelativePath = String(entry?.path || '').trim();
return {
jobId: `local-ocr-${status}:${sourceRootRelativePath || 'unknown'}:${timestamp}`,
ownerDocumentId: String(entry?.ownerDocumentId || entry?.documentId || currentWebShellDocumentId() || '').trim(),
sourceRootRelativePath,
rootUri: String(entry?.rootUri || localOcrTaskState.rootUri || '').trim(),
ocrRootRelativePath: '',
provider: String(provider || localOcrProvider()),
status,
stageLabel: message || statusTextForLocalOcrJob({ status }),
stale: false,
updatedAtMs: timestamp,
finishedAtMs: status === 'failed' ? timestamp : null,
error: status === 'failed' ? String(message || '') : '',
};
};
const statusTextForLocalOcrJob = (job) => {
const status = String(job?.status || '').trim();
if (job?.stageLabel) return String(job.stageLabel);
return status === 'done' ? '已识别'
: status === 'failed' ? '识别失败'
: status === 'stale' ? '来源已变化'
: status === 'running' ? '处理中'
: status || '未知';
};
const localOcrTaskCategory = (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 localOcrTaskProgress = (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 localOcrTaskFilterLabel = (filter) => {
return filter === 'active' ? '进行中'
: filter === 'completed' ? '已完成'
: filter === 'attention' ? '需处理'
: '全部';
};
const ensureLocalOcrTaskDock = () => {
let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
if (!(dock instanceof HTMLElement)) {
dock = document.createElement('section');
dock.className = 'mnote-local-ocr-task-dock';
dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
dock.innerHTML = '<div class="mnote-local-ocr-task-drawer" data-testid="mnote-local-ocr-task-drawer" hidden><div class="mnote-local-ocr-task-panel"><div class="mnote-local-ocr-task-head"><div><strong>后台任务</strong><span data-mnote-local-ocr-task-summary>暂无任务</span></div><button type="button" class="mnote-local-ocr-task-close" data-mnote-local-ocr-task-close aria-label="收起后台任务">×</button></div><div class="mnote-local-ocr-task-tabs" data-mnote-local-ocr-task-tabs></div><div class="mnote-local-ocr-task-toolbar"><span data-mnote-local-ocr-task-filter-label>进行中</span><button type="button" data-mnote-local-ocr-task-clear-completed>清除已完成</button></div><div class="mnote-local-ocr-task-list" data-testid="mnote-local-ocr-task-list"></div></div></div>';
document.body.appendChild(dock);
}
let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
if (!(toggle instanceof HTMLButtonElement)) {
const actions = document.querySelector('.wolai-topbar-actions');
toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle';
toggle.setAttribute('title', 'OCR 设置');
toggle.setAttribute('aria-label', 'OCR 设置');
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
toggle.setAttribute('data-mnote-action', 'open-ocr-settings');
toggle.innerHTML = '<span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span>';
if (actions instanceof HTMLElement) actions.appendChild(toggle);
else document.body.appendChild(toggle);
}
bindLocalOcrTopbarAction();
if (dock.getAttribute('data-mnote-local-ocr-bound') === 'true') return dock;
dock.setAttribute('data-mnote-local-ocr-bound', 'true');
dock.addEventListener('click', (event) => {
const closeButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-close]') : null;
if (closeButton instanceof HTMLElement) {
localOcrTaskState.drawerOpen = false;
renderLocalOcrTaskDock();
return;
}
const clearButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear]') : null;
if (clearButton instanceof HTMLElement) {
const sourcePath = clearButton.getAttribute('data-mnote-local-ocr-task-clear') || '';
if (sourcePath) {
localOcrTaskState.jobsBySource.delete(sourcePath);
renderLocalOcrTaskDock();
}
return;
}
const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-tab]') : null;
if (tabButton instanceof HTMLElement) {
localOcrTaskState.taskFilter = tabButton.getAttribute('data-mnote-local-ocr-task-tab') || 'active';
renderLocalOcrTaskDock();
return;
}
const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear-completed]') : null;
if (clearCompleted instanceof HTMLElement) {
Array.from(localOcrTaskState.jobsBySource.entries()).forEach(([key, job]) => {
if (localOcrTaskCategory(job) === 'completed') localOcrTaskState.jobsBySource.delete(key);
});
renderLocalOcrTaskDock();
return;
}
const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null;
if (deleteButton instanceof HTMLElement) {
const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || '';
if (sourcePath) {
void deleteLocalOcrJob(sourcePath).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
document.documentElement.setAttribute('data-mnote-local-ocr-delete-error', message);
console.warn('mnote local OCR 删除失败', error);
});
}
return;
}
const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null;
if (openButton instanceof HTMLElement) {
const sourcePath = openButton.getAttribute('data-mnote-local-ocr-task-open') || '';
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
if (job) {
void openResourceInActiveTab({
kind: 'markdown',
title: String(job.ocrRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR',
path: job.ocrRootRelativePath,
objectIdentity: `local-ocr:${job.ocrRootRelativePath}`,
assetId: `local-ocr:${job.ocrRootRelativePath}`,
documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
workspaceId: currentWebShellWorkspaceId() || '',
sourceKind: 'local_folder',
rootUri: localOcrTaskState.rootUri || '',
resourceKind: 'markdown',
}).catch((error) => console.warn('mnote local OCR 任务打开失败', error));
}
}
const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null;
if (retryButton instanceof HTMLElement) {
const sourcePath = retryButton.getAttribute('data-mnote-local-ocr-task-retry') || '';
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
if (job) {
const entry = {
sourceKind: 'local_folder',
rootUri: localOcrTaskState.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() || '',
localOcrJob: job,
};
void createLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 任务重试失败', error));
}
}
});
return dock;
};
const renderLocalOcrTaskDock = () => {
const dock = ensureLocalOcrTaskDock();
const jobs = Array.from(localOcrTaskState.jobsBySource.values())
.sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
const activeJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'active');
const attentionJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'attention');
const completedJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'completed');
const runningCount = activeJobs.length;
const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-local-ocr-task-toggle"], [data-testid="mnote-floating-task-toggle"]'))
.filter((node) => node instanceof HTMLButtonElement);
taskToggles.forEach((toggle) => {
const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings';
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
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', localOcrTaskState.drawerOpen ? 'true' : 'false');
toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0);
const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]');
if (badge instanceof HTMLElement) {
badge.textContent = String(runningCount > 0 ? runningCount : jobs.length);
badge.hidden = jobs.length === 0;
} else {
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
}
});
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
const summary = dock.querySelector('[data-mnote-local-ocr-task-summary]');
if (summary instanceof HTMLElement) {
summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`;
}
const tabs = dock.querySelector('[data-mnote-local-ocr-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-local-ocr-task-tab', key);
button.setAttribute('aria-selected', localOcrTaskState.taskFilter === key ? 'true' : 'false');
button.textContent = `${label} ${count}`;
tabs.appendChild(button);
});
}
const filterLabel = dock.querySelector('[data-mnote-local-ocr-task-filter-label]');
if (filterLabel instanceof HTMLElement) filterLabel.textContent = localOcrTaskFilterLabel(localOcrTaskState.taskFilter);
const clearCompleted = dock.querySelector('[data-mnote-local-ocr-task-clear-completed]');
if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0;
const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
if (!(list instanceof HTMLElement)) return;
list.replaceChildren();
const visibleJobs = jobs.filter((job) => {
return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter;
});
if (!visibleJobs.length) {
const empty = document.createElement('div');
empty.className = 'mnote-local-ocr-task-empty';
empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务';
list.appendChild(empty);
return;
}
visibleJobs.forEach((job) => {
const row = document.createElement('div');
row.className = 'mnote-local-ocr-task-row';
row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job));
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
const category = localOcrTaskCategory(job);
const progress = localOcrTaskProgress(job);
row.innerHTML = '<div class="mnote-local-ocr-task-main"><div class="mnote-local-ocr-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-local-ocr-task-progress" role="progressbar"><i></i></div></div><div class="mnote-local-ocr-task-actions"><button type="button" data-mnote-local-ocr-task-open>打开</button><button type="button" data-mnote-local-ocr-task-retry>重试</button><button type="button" data-mnote-local-ocr-task-clear>清除</button><button type="button" data-mnote-local-ocr-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 = statusTextForLocalOcrJob(job);
const progressBar = row.querySelector('.mnote-local-ocr-task-progress');
const progressValue = row.querySelector('.mnote-local-ocr-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-local-ocr-task-open]');
if (open instanceof HTMLButtonElement) {
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
open.disabled = !job.ocrRootRelativePath;
}
const retry = row.querySelector('[data-mnote-local-ocr-task-retry]');
if (retry instanceof HTMLButtonElement) {
retry.setAttribute('data-mnote-local-ocr-task-retry', String(job.sourceRootRelativePath || ''));
retry.hidden = !['failed', 'stale'].includes(String(job.status || ''));
}
const clear = row.querySelector('[data-mnote-local-ocr-task-clear]');
if (clear instanceof HTMLButtonElement) {
clear.setAttribute('data-mnote-local-ocr-task-clear', String(job.sourceRootRelativePath || ''));
}
const deleteOcr = row.querySelector('[data-mnote-local-ocr-task-delete]');
if (deleteOcr instanceof HTMLButtonElement) {
deleteOcr.setAttribute('data-mnote-local-ocr-task-delete', String(job.sourceRootRelativePath || ''));
deleteOcr.hidden = !job.ocrRootRelativePath;
}
list.appendChild(row);
});
};
const loadLocalOcrJobs = async (rootUri) => {
const normalizedRoot = String(rootUri || '').trim();
if (!normalizedRoot) return;
localOcrTaskState.rootUri = normalizedRoot;
const url = new URL('/api/local-folder/ocr/jobs', window.location.origin);
url.searchParams.set('rootUri', normalizedRoot);
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;
(Array.isArray(payload.jobs) ? payload.jobs : []).forEach((job) => {
if (job && typeof job === 'object') job.rootUri = normalizedRoot;
updateLocalOcrTaskState(job);
});
renderLocalOcrTaskDock();
};
const ensureLocalOcrTaskEvents = (rootUri) => {
const normalizedRoot = String(rootUri || '').trim();
if (!normalizedRoot || typeof window.EventSource !== 'function') return;
if (localOcrTaskState.eventSource && localOcrTaskState.rootUri === normalizedRoot) return;
if (localOcrTaskState.eventSource) {
try { localOcrTaskState.eventSource.close(); } catch (_) {}
localOcrTaskState.eventSource = null;
}
localOcrTaskState.rootUri = normalizedRoot;
const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', normalizedRoot);
const eventSource = new EventSource(url.toString());
localOcrTaskState.eventSource = eventSource;
eventSource.addEventListener('local_ocr.job.updated', (event) => {
let payload = null;
try { payload = JSON.parse(event.data || '{}'); } catch (_) {}
if (payload?.job) {
if (payload.job && typeof payload.job === 'object') payload.job.rootUri = payload.rootUri || normalizedRoot;
updateLocalOcrTaskState(payload.job);
dispatchLocalOcrFileTreeRefresh(payload.job, payload.rootUri || normalizedRoot);
}
});
};
const openLocalOcrSidecar = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
if (!ocrPath || typeof openResourceInActiveTab !== 'function') return false;
const title = ocrPath.split('/').filter(Boolean).pop() || 'OCR';
return await openResourceInActiveTab({
kind: 'markdown',
title,
path: ocrPath,
objectIdentity: `local-ocr:${ocrPath}`,
assetId: `local-ocr:${ocrPath}`,
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
ownerDocumentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
workspaceId: String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(),
sourceKind: 'local_folder',
rootUri: String(entry.rootUri || '').trim(),
resourceKind: 'markdown',
paneRole: normalizePaneRole(entry.paneRole),
});
};
const insertLocalOcrLink = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
if (!ocrPath) return false;
const response = await fetch('/api/local-folder/ocr/insert', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri: String(entry.rootUri || '').trim(),
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
ocrRootRelativePath: ocrPath,
mode: 'link',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `local_ocr_insert_failed_${response.status}`);
}
setLocalOcrStatus(entry, 'done', 'OCR 链接已插入正文', target);
return true;
};
const createLocalOcrJob = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return null;
const provider = localOcrProvider();
const entryRootUri = String(entry.rootUri || '').trim();
if (entryRootUri) localOcrTaskState.rootUri = entryRootUri;
const startedAt = Date.now();
const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', provider, '处理中', startedAt);
setLocalOcrStatus(entry, 'running', 'OCR 处理中', pendingJob);
updateLocalOcrTaskState(pendingJob);
const body = {
rootUri: String(entry.rootUri || '').trim(),
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
sourceRootRelativePath: String(entry.path || '').trim(),
provider,
};
if (provider === 'mock') {
body.mockMarkdown = `# OCR Result\n\n${entry.title || entry.path} OCR UI smoke text`;
}
const response = await fetch('/api/local-folder/ocr/jobs', {
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 || `local_ocr_job_failed_${response.status}`;
const failedJob = {
...pendingJob,
status: 'failed',
stageLabel: message,
updatedAtMs: Date.now(),
finishedAtMs: Date.now(),
error: message,
};
setLocalOcrStatus(entry, 'failed', message, failedJob);
updateLocalOcrTaskState(failedJob);
throw new Error(message);
}
const job = payload.job && typeof payload.job === 'object' ? { ...payload.job, rootUri: entryRootUri } : null;
setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job);
if (job) updateLocalOcrTaskState(job);
return job;
};
const deleteLocalOcrJob = async (sourceRootRelativePath) => {
const sourcePath = String(sourceRootRelativePath || '').trim();
if (!sourcePath) return false;
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
const rootUri = String(job?.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim();
const response = await fetch('/api/local-folder/ocr/delete', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri,
sourceRootRelativePath: sourcePath,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `local_ocr_delete_failed_${response.status}`);
}
if (payload.deleted !== true) {
throw new Error('local_ocr_delete_noop');
}
localOcrTaskState.jobsBySource.delete(sourcePath);
renderLocalOcrTaskDock();
const ocrPath = String(job?.ocrRootRelativePath || '').trim();
if (ocrPath) {
dispatchLocalOcrFileTreeRefresh({ ...job, status: 'stale', updatedAtMs: Date.now(), ocrRootRelativePath: ocrPath }, localOcrTaskState.rootUri);
}
return true;
};
const maybeAutoCreateLocalOcrJob = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return;
if (!await localOcrAutoEnabled()) return;
const existing = await readLocalOcrStatus(entry);
const status = String(existing?.status || '').trim();
if (existing && !existing.stale && ['done', 'running'].includes(status)) {
updateLocalOcrTaskState(existing);
return;
}
await createLocalOcrJob(entry);
};
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 localOcrCandidatesFromActiveMarkdown = (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 localOcrCandidatesForActiveTarget = (paneRole = 'primary') => {
const activeResource = activeResourceTabEntry(paneRole);
if (isLocalOcrSourceEntry(activeResource)) return [activeResource];
return localOcrCandidatesFromActiveMarkdown(paneRole);
};
const runManualLocalOcrForActiveTarget = async (toggle) => {
ensureLocalOcrTaskDock();
const candidates = localOcrCandidatesForActiveTarget('primary');
if (!candidates.length) {
if (localOcrTaskState.jobsBySource.size > 0) {
localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen;
renderLocalOcrTaskDock();
}
return [];
}
if (toggle instanceof HTMLButtonElement) toggle.disabled = true;
const jobs = [];
let createdCount = 0;
try {
for (const entry of candidates) {
try {
ensureLocalOcrTaskEvents(entry.rootUri);
const existing = await readLocalOcrStatus(entry);
const existingStatus = String(existing?.status || '').trim();
if (existing && !existing.stale && ['done', 'running'].includes(existingStatus)) {
updateLocalOcrTaskState(existing);
jobs.push(existing);
continue;
}
const job = await createLocalOcrJob(entry);
if (job) {
createdCount += 1;
jobs.push(job);
}
} catch (error) {
console.warn('mnote local OCR 手动任务失败', entry?.path, error);
if (localOcrTaskState.jobsBySource.has(String(entry?.path || '').trim())) {
createdCount += 1;
}
}
}
if (createdCount === 0 && localOcrTaskState.jobsBySource.size > 0) {
localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen;
}
} finally {
if (toggle instanceof HTMLButtonElement) toggle.disabled = false;
renderLocalOcrTaskDock();
}
return jobs;
};
window.addEventListener('mnote:local-ocr-settings-action', (event) => {
const action = String(event?.detail?.action || '').trim();
if (action === 'run-active') {
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
void runManualLocalOcrForActiveTarget(toggle instanceof HTMLButtonElement ? toggle : null).catch((error) => {
console.warn('mnote local OCR 设置入口识别失败', error);
});
return;
}
if (action === 'tasks') {
ensureLocalOcrTaskDock();
localOcrTaskState.drawerOpen = true;
renderLocalOcrTaskDock();
}
});
const renderLocalOcrToolbar = (entry) => {
if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]');
if (!(toolbar instanceof HTMLElement)) return;
const runButton = toolbar.querySelector('[data-mnote-local-ocr-action="run"]');
const openButton = toolbar.querySelector('[data-mnote-local-ocr-action="open"]');
const insertButton = toolbar.querySelector('[data-mnote-local-ocr-action="insert"]');
if (runButton instanceof HTMLButtonElement) {
runButton.addEventListener('click', async () => {
runButton.disabled = true;
try {
await createLocalOcrJob(entry);
} catch (error) {
console.warn('mnote local OCR 生成失败', error);
} finally {
runButton.disabled = false;
}
});
}
if (openButton instanceof HTMLButtonElement) {
openButton.addEventListener('click', () => {
void openLocalOcrSidecar(entry, entry.localOcrJob).catch((error) => {
console.warn('mnote local OCR 打开失败', error);
});
});
}
if (insertButton instanceof HTMLButtonElement) {
insertButton.addEventListener('click', () => {
void insertLocalOcrLink(entry, entry.localOcrJob).catch((error) => {
console.warn('mnote local OCR 插入失败', error);
setLocalOcrStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.localOcrJob || null);
});
});
}
setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void readLocalOcrStatus(entry).then((job) => {
if (!job) return;
setLocalOcrStatus(entry, job.stale ? 'stale' : String(job.status || 'done'), job.stale ? 'OCR 需更新' : 'OCR 已完成', job);
updateLocalOcrTaskState(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);
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error));
installPassiveResourceWatch(entry);
return;
}
if (entry.kind === 'pdf') {
await openInlinePdfResourceTab(entry, input);
applyEvidenceLocatorToEntry(entry, input);
if (isLocalOcrSourceEntry(entry)) {
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error));
}
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 (isLocalOcrSourceEntry(entry)) {
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error));
}
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', '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 {
bindLocalOcrTopbarAction();
} catch (_) {}
return {
activateMainEditorTab,
bindMainEditorPageTab,
buildOpenEditorsSnapshot,
closeResourceTabsForPane,
normalizePaneRole,
openResourceInActiveTab,
resolveResourceOpen,
syncResourceSessionTabGuards,
};
};