2026-05-26 01:15:28 +08:00
|
|
|
|
import {
|
2026-06-02 17:17:49 +08:00
|
|
|
|
localFileOpenPathFromTiptapHref,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
localMarkdownDocumentIdFromRelativePath,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
localMarkdownRelativePathFromDocumentId,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
localizeTiptapAssetUrls,
|
|
|
|
|
|
} from './document-tiptap-conversion-runtime.js';
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @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
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
export const createResourceTabRuntime = (dependencies = {}) => {
|
|
|
|
|
|
const {
|
|
|
|
|
|
loadRuntime,
|
|
|
|
|
|
createEditorViewBinding,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
ensureLocalFolderEventChannel,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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;
|
2026-06-04 23:00:53 +08:00
|
|
|
|
const readonlyCodePreviewMaxChars = 1_500_000;
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
|
2026-06-01 09:29:12 +08:00
|
|
|
|
let onlyofficeBridgeReadyListenerBound = false;
|
2026-05-26 01:15:28 +08:00
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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 '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const currentWebShellWorkspaceId = () => {
|
|
|
|
|
|
const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
|
|
|
|
|
|
if (explicit) return explicit;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-05-26 01:15:28 +08:00
|
|
|
|
try {
|
|
|
|
|
|
return currentUrl().searchParams.get('workspaceId') || '';
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 09:29:12 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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 '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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 {
|
2026-06-01 09:29:12 +08:00
|
|
|
|
return currentUrl().searchParams.get('sourceKind')
|
|
|
|
|
|
|| document.body?.dataset?.mnoteSourceKind
|
|
|
|
|
|
|| currentFileTreeWorkspacePath()?.sourceKind
|
|
|
|
|
|
|| '';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
} catch (_) {
|
2026-06-01 09:29:12 +08:00
|
|
|
|
return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || '';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const currentWebShellRootUri = () => {
|
|
|
|
|
|
try {
|
2026-06-01 09:29:12 +08:00
|
|
|
|
return currentUrl().searchParams.get('rootUri')
|
|
|
|
|
|
|| document.body?.dataset?.mnoteRootUri
|
|
|
|
|
|
|| currentFileTreeWorkspacePath()?.rootUri
|
|
|
|
|
|
|| '';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
} catch (_) {
|
2026-06-01 09:29:12 +08:00
|
|
|
|
return document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || '';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-01 09:29:12 +08:00
|
|
|
|
const structuredObjectIdentity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : null;
|
|
|
|
|
|
const objectIdentityText = structuredObjectIdentity ? '' : String(objectIdentity || '').trim();
|
|
|
|
|
|
const id = String(objectIdentityText || documentId || assetId || relativePath || '').trim();
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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(),
|
2026-06-01 09:29:12 +08:00
|
|
|
|
objectIdentity: structuredObjectIdentity || objectIdentityText,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
assetId: String(assetId || '').trim(),
|
|
|
|
|
|
resourceKind: String(resourceKind || '').trim(),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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';
|
2026-06-04 23:00:53 +08:00
|
|
|
|
const editable = kind === 'markdown' || kind === 'text';
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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());
|
2026-05-28 22:01:44 +08:00
|
|
|
|
if (!entry) return;
|
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
|
entry.lastActiveAt = timestamp;
|
|
|
|
|
|
if (entry.session) entry.session.lastActiveAt = timestamp;
|
2026-05-26 01:15:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 '';
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
|
|
|
|
|
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') {
|
|
|
|
|
|
return bufferDirtyState;
|
|
|
|
|
|
}
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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 '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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) => {
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const syncResourceSessionTabGuards = (session) => {
|
|
|
|
|
|
if (!session || session.sessionKind !== 'resource') return;
|
|
|
|
|
|
resourceTabRegistry.forEach((entry) => {
|
|
|
|
|
|
if (entry.session === session) syncResourceTabCloseGuard(entry);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 09:29:12 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
|
|
|
|
|
const active = entry?.tab instanceof HTMLElement
|
2026-05-26 01:15:28 +08:00
|
|
|
|
? entry.tab.getAttribute('aria-selected') === 'true'
|
2026-05-28 22:01:44 +08:00
|
|
|
|
: 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);
|
2026-06-01 09:29:12 +08:00
|
|
|
|
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
|
2026-06-05 23:00:53 +08:00
|
|
|
|
const officeOpenMode = kind === 'office' ? officeOpenModeForEntry(entry) : '';
|
2026-06-01 09:29:12 +08:00
|
|
|
|
const onlyofficeSessionId = String(
|
|
|
|
|
|
officeBridgeDebug?.bridgeSessionId
|
|
|
|
|
|
|| entry?.onlyofficeSessionId
|
|
|
|
|
|
|| entry?.bridgeSessionId
|
|
|
|
|
|
|| '',
|
|
|
|
|
|
).trim();
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
return {
|
|
|
|
|
|
objectIdentity,
|
|
|
|
|
|
workspacePath: buildWorkspacePath({
|
2026-06-05 23:00:53 +08:00
|
|
|
|
workspacePath: workspacePathSeed,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
workspaceId,
|
|
|
|
|
|
sourceKind,
|
|
|
|
|
|
rootUri,
|
|
|
|
|
|
relativePath,
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
objectIdentity,
|
|
|
|
|
|
assetId,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
resourceKind: snapshotResourceKind,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
}),
|
|
|
|
|
|
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,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
officeOpenMode,
|
2026-06-01 09:29:12 +08:00
|
|
|
|
onlyofficeSessionId,
|
|
|
|
|
|
bridgeSessionId: onlyofficeSessionId,
|
|
|
|
|
|
bridgeSessionReady: Boolean(onlyofficeSessionId),
|
|
|
|
|
|
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
|
|
|
|
|
|
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
|
2026-05-28 22:01:44 +08:00
|
|
|
|
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
2026-06-05 23:00:53 +08:00
|
|
|
|
preview: officeOpenMode === 'preview',
|
2026-05-28 22:01:44 +08:00
|
|
|
|
pinned: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
2026-05-26 01:15:28 +08:00
|
|
|
|
|
|
|
|
|
|
const buildOpenEditorsSnapshot = () => {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const generatedAt = Date.now();
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const pageEntries = ['primary', 'secondary'].map((paneRole) => {
|
|
|
|
|
|
const nodes = resourceTabHostNodes(paneRole);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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}`;
|
2026-06-01 09:29:12 +08:00
|
|
|
|
const workspaceObjectIdentity = {
|
|
|
|
|
|
objectKind: 'page',
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
blockId: null,
|
|
|
|
|
|
assetId: null,
|
|
|
|
|
|
};
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const active = nodes.pageTab instanceof HTMLElement
|
|
|
|
|
|
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
|
|
|
|
|
: false;
|
|
|
|
|
|
const pageSession = documentSessionForPane(paneRole, documentId);
|
|
|
|
|
|
const dirtyState = documentSessionDirtyState(pageSession);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const pageTitle = nodes.pageTab instanceof HTMLElement
|
|
|
|
|
|
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
|
|
|
|
|
|
: '页面';
|
|
|
|
|
|
return {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
objectIdentity,
|
|
|
|
|
|
workspacePath: buildWorkspacePath({
|
|
|
|
|
|
workspaceId,
|
|
|
|
|
|
sourceKind,
|
|
|
|
|
|
rootUri,
|
|
|
|
|
|
relativePath,
|
|
|
|
|
|
documentId,
|
2026-06-01 09:29:12 +08:00
|
|
|
|
objectIdentity: workspaceObjectIdentity,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
assetId: '',
|
|
|
|
|
|
resourceKind: 'page',
|
|
|
|
|
|
}),
|
2026-05-26 01:15:28 +08:00
|
|
|
|
paneRole,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
documentId,
|
|
|
|
|
|
workspaceId,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
title: pageTitle || '页面',
|
|
|
|
|
|
kind: 'page',
|
2026-05-28 22:01:44 +08:00
|
|
|
|
editorKind: 'page',
|
2026-05-26 01:15:28 +08:00
|
|
|
|
badgeKind: 'code',
|
2026-05-28 22:01:44 +08:00
|
|
|
|
active,
|
|
|
|
|
|
dirtyState,
|
|
|
|
|
|
dirtyGuard: dirtyState,
|
|
|
|
|
|
lastActiveAt: active ? generatedAt : 0,
|
|
|
|
|
|
preview: false,
|
|
|
|
|
|
pinned: true,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
}).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) => {
|
2026-05-28 22:01:44 +08:00
|
|
|
|
resources.push(openEditorsSnapshotEntry(entry, key, generatedAt));
|
2026-05-26 01:15:28 +08:00
|
|
|
|
});
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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 || '';
|
2026-05-26 01:15:28 +08:00
|
|
|
|
return {
|
|
|
|
|
|
schema: 'mnote.open_editors_snapshot.v1',
|
2026-05-28 22:01:44 +08:00
|
|
|
|
generatedAt,
|
|
|
|
|
|
activeObjectIdentity,
|
|
|
|
|
|
editors,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
resourceEditors: resources,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
groups,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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 || '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-28 22:01:44 +08:00
|
|
|
|
syncActiveResourceWidthType(activeEntry, role);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
if (activeEntry) markIntendedSlashRoot(activeEntry);
|
|
|
|
|
|
if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
|
|
|
|
|
|
if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
|
|
|
|
|
|
syncActiveResourceUrlState(activeResource, role);
|
2026-06-04 21:00:42 +08:00
|
|
|
|
syncActivePassiveResourceWatch(role, activeResource);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
const closeResourceTab = async (objectIdentity) => {
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const key = String(objectIdentity || '').trim();
|
|
|
|
|
|
const entry = resourceTabRegistry.get(key);
|
|
|
|
|
|
if (!entry) return;
|
2026-05-28 22:01:44 +08:00
|
|
|
|
await refreshResourceTabBufferState(entry);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
releaseInlinePdfResource(entry);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
panel.setAttribute('data-resource-path', String(input.path || '').trim());
|
2026-05-26 01:15:28 +08:00
|
|
|
|
panel.hidden = true;
|
|
|
|
|
|
nodes.strip.append(tab);
|
|
|
|
|
|
nodes.panelRoot.append(panel);
|
|
|
|
|
|
return {
|
|
|
|
|
|
objectIdentity,
|
|
|
|
|
|
registryKey,
|
|
|
|
|
|
paneRole,
|
|
|
|
|
|
title,
|
|
|
|
|
|
kind,
|
2026-05-28 22:01:44 +08:00
|
|
|
|
badgeKind: resourceTabBadgeKind(input, kind),
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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(),
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const releaseResourceTabEntryRuntime = (entry) => {
|
|
|
|
|
|
if (!entry) return;
|
2026-06-04 18:51:16 +08:00
|
|
|
|
releaseInlinePdfResource(entry);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-04 21:00:42 +08:00
|
|
|
|
closePassiveResourceWatch(entry);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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));
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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) => {
|
2026-06-05 23:00:53 +08:00
|
|
|
|
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
|
2026-06-04 18:51:16 +08:00
|
|
|
|
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
|
|
|
|
|
if (!(root instanceof HTMLElement)) return;
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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 || '');
|
2026-06-04 18:51:16 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (entry.kind === 'pdf' && entry.inlinePdfSourceHref) {
|
|
|
|
|
|
void openPassiveResourceTab(entry, { ...(entry.lastPassiveInput || {}), href: withResourceReloadToken(entry.inlinePdfSourceHref) });
|
2026-05-28 22:01:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 21:00:42 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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;
|
2026-06-04 21:00:42 +08:00
|
|
|
|
if (!isActiveResourceTabEntry(entry)) {
|
|
|
|
|
|
closePassiveResourceWatch(entry);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
closeInactivePassiveResourceWatches(entry);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 21:00:42 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:07:42 +08:00
|
|
|
|
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';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:07:42 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const localOcrTaskState = {
|
|
|
|
|
|
rootUri: '',
|
|
|
|
|
|
jobsBySource: new Map(),
|
|
|
|
|
|
drawerOpen: false,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
taskFilter: 'active',
|
2026-06-01 10:30:42 +08:00
|
|
|
|
eventSource: null,
|
2026-06-02 17:17:49 +08:00
|
|
|
|
fileTreeRefreshKeys: new Set(),
|
2026-06-01 10:30:42 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const localOcrJobKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim();
|
|
|
|
|
|
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const updateLocalOcrTaskState = (job) => {
|
|
|
|
|
|
const key = localOcrJobKey(job);
|
|
|
|
|
|
if (!key) return;
|
2026-06-02 17:17:49 +08:00
|
|
|
|
if (String(job?.status || '').trim() === 'deleted') {
|
|
|
|
|
|
localOcrTaskState.jobsBySource.delete(key);
|
|
|
|
|
|
renderLocalOcrTaskDock();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-01 10:30:42 +08:00
|
|
|
|
localOcrTaskState.jobsBySource.set(key, job);
|
|
|
|
|
|
renderLocalOcrTaskDock();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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');
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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 || '') : '',
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:30:42 +08:00
|
|
|
|
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 || '未知';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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' ? '需处理'
|
|
|
|
|
|
: '全部';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const ensureLocalOcrTaskDock = () => {
|
|
|
|
|
|
let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
|
2026-06-02 17:17:49 +08:00
|
|
|
|
if (!(dock instanceof HTMLElement)) {
|
|
|
|
|
|
dock = document.createElement('section');
|
|
|
|
|
|
dock.className = 'mnote-local-ocr-task-dock';
|
|
|
|
|
|
dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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>';
|
2026-06-02 17:17:49 +08:00
|
|
|
|
document.body.appendChild(dock);
|
2026-06-01 10:30:42 +08:00
|
|
|
|
}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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';
|
2026-06-04 18:51:16 +08:00
|
|
|
|
toggle.setAttribute('title', 'OCR 设置');
|
|
|
|
|
|
toggle.setAttribute('aria-label', 'OCR 设置');
|
2026-06-02 17:17:49 +08:00
|
|
|
|
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
|
2026-06-04 18:51:16 +08:00
|
|
|
|
toggle.setAttribute('data-mnote-action', 'open-ocr-settings');
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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');
|
2026-06-01 10:30:42 +08:00
|
|
|
|
dock.addEventListener('click', (event) => {
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-01 10:30:42 +08:00
|
|
|
|
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));
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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';
|
2026-06-04 18:51:16 +08:00
|
|
|
|
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
|
2026-06-05 23:00:53 +08:00
|
|
|
|
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
|
|
|
|
|
|
toggle.setAttribute('title', opensSettings ? label : taskLabel);
|
|
|
|
|
|
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
|
2026-06-01 10:30:42 +08:00
|
|
|
|
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
|
});
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
|
|
|
|
|
|
if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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;
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
|
|
|
|
|
|
if (!(list instanceof HTMLElement)) return;
|
|
|
|
|
|
list.replaceChildren();
|
2026-06-05 23:00:53 +08:00
|
|
|
|
const visibleJobs = jobs.filter((job) => {
|
|
|
|
|
|
return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!visibleJobs.length) {
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const empty = document.createElement('div');
|
|
|
|
|
|
empty.className = 'mnote-local-ocr-task-empty';
|
2026-06-05 23:00:53 +08:00
|
|
|
|
empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务';
|
2026-06-01 10:30:42 +08:00
|
|
|
|
list.appendChild(empty);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
|
visibleJobs.forEach((job) => {
|
2026-06-01 10:30:42 +08:00
|
|
|
|
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 || ''));
|
2026-06-05 23:00:53 +08:00
|
|
|
|
row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job));
|
2026-06-01 10:30:42 +08:00
|
|
|
|
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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}%`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-01 10:30:42 +08:00
|
|
|
|
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 || ''));
|
|
|
|
|
|
}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-01 10:30:42 +08:00
|
|
|
|
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;
|
2026-06-02 17:17:49 +08:00
|
|
|
|
(Array.isArray(payload.jobs) ? payload.jobs : []).forEach((job) => {
|
|
|
|
|
|
if (job && typeof job === 'object') job.rootUri = normalizedRoot;
|
|
|
|
|
|
updateLocalOcrTaskState(job);
|
|
|
|
|
|
});
|
2026-06-01 10:30:42 +08:00
|
|
|
|
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 (_) {}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-01 10:30:42 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 10:07:42 +08:00
|
|
|
|
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();
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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);
|
2026-06-01 10:07:42 +08:00
|
|
|
|
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}`;
|
2026-06-02 17:17:49 +08:00
|
|
|
|
const failedJob = {
|
|
|
|
|
|
...pendingJob,
|
|
|
|
|
|
status: 'failed',
|
|
|
|
|
|
stageLabel: message,
|
|
|
|
|
|
updatedAtMs: Date.now(),
|
|
|
|
|
|
finishedAtMs: Date.now(),
|
|
|
|
|
|
error: message,
|
|
|
|
|
|
};
|
|
|
|
|
|
setLocalOcrStatus(entry, 'failed', message, failedJob);
|
|
|
|
|
|
updateLocalOcrTaskState(failedJob);
|
2026-06-01 10:07:42 +08:00
|
|
|
|
throw new Error(message);
|
|
|
|
|
|
}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
const job = payload.job && typeof payload.job === 'object' ? { ...payload.job, rootUri: entryRootUri } : null;
|
2026-06-01 10:07:42 +08:00
|
|
|
|
setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job);
|
2026-06-01 10:30:42 +08:00
|
|
|
|
if (job) updateLocalOcrTaskState(job);
|
2026-06-01 10:07:42 +08:00
|
|
|
|
return job;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 17:17:49 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-01 10:07:42 +08:00
|
|
|
|
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);
|
2026-06-01 10:30:42 +08:00
|
|
|
|
ensureLocalOcrTaskDock();
|
|
|
|
|
|
ensureLocalOcrTaskEvents(entry.rootUri);
|
|
|
|
|
|
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
|
2026-06-01 10:07:42 +08:00
|
|
|
|
void readLocalOcrStatus(entry).then((job) => {
|
|
|
|
|
|
if (!job) return;
|
|
|
|
|
|
setLocalOcrStatus(entry, job.stale ? 'stale' : String(job.status || 'done'), job.stale ? 'OCR 需更新' : 'OCR 已完成', job);
|
2026-06-01 10:30:42 +08:00
|
|
|
|
updateLocalOcrTaskState(job);
|
2026-06-01 10:07:42 +08:00
|
|
|
|
}).catch(() => undefined);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const createResourceSession = (entry, input, readResult) => {
|
|
|
|
|
|
const resourcePath = String(input.path || '');
|
2026-06-02 17:17:49 +08:00
|
|
|
|
const resourceDocumentId = localMarkdownDocumentIdFromRelativePath(resourcePath) || entry.objectIdentity;
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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',
|
2026-06-02 17:17:49 +08:00
|
|
|
|
documentId: resourceDocumentId,
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
2026-05-28 22:01:44 +08:00
|
|
|
|
if (typeof ensureLocalFolderEventChannel === 'function') {
|
|
|
|
|
|
ensureLocalFolderEventChannel(session);
|
|
|
|
|
|
}
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 23:00:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-01 09:29:12 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:51:16 +08:00
|
|
|
|
const openPassiveResourceTab = async (entry, input) => {
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const href = String(input.officeUrl || input.href || '').trim();
|
|
|
|
|
|
if (entry.kind === 'image') {
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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>';
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const img = entry.panel.querySelector('img');
|
|
|
|
|
|
if (img instanceof HTMLImageElement) {
|
|
|
|
|
|
img.src = href;
|
|
|
|
|
|
img.alt = entry.title;
|
|
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
applyEvidenceLocatorToEntry(entry, input);
|
2026-06-02 17:17:49 +08:00
|
|
|
|
ensureLocalOcrTaskDock();
|
|
|
|
|
|
ensureLocalOcrTaskEvents(entry.rootUri);
|
|
|
|
|
|
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
|
|
|
|
|
|
void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error));
|
2026-05-28 22:01:44 +08:00
|
|
|
|
installPassiveResourceWatch(entry);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-02 17:17:49 +08:00
|
|
|
|
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
2026-05-26 01:15:28 +08:00
|
|
|
|
const frame = entry.panel.querySelector('iframe');
|
|
|
|
|
|
if (frame instanceof HTMLIFrameElement) {
|
|
|
|
|
|
frame.title = entry.title;
|
2026-06-01 09:29:12 +08:00
|
|
|
|
if (entry.kind === 'office') {
|
|
|
|
|
|
ensureOnlyofficeBridgeReadyListener();
|
|
|
|
|
|
frame.addEventListener('load', () => {
|
|
|
|
|
|
syncOpenEditorsSnapshot();
|
|
|
|
|
|
}, { once: true });
|
|
|
|
|
|
}
|
2026-05-26 01:15:28 +08:00
|
|
|
|
frame.src = href;
|
2026-06-04 18:51:16 +08:00
|
|
|
|
entry.passiveFrameSrc = href;
|
2026-05-26 01:15:28 +08:00
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
applyEvidenceLocatorToEntry(entry, input);
|
2026-06-02 17:17:49 +08:00
|
|
|
|
if (isLocalOcrSourceEntry(entry)) {
|
|
|
|
|
|
ensureLocalOcrTaskDock();
|
|
|
|
|
|
ensureLocalOcrTaskEvents(entry.rootUri);
|
|
|
|
|
|
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
|
|
|
|
|
|
void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error));
|
|
|
|
|
|
}
|
2026-05-28 22:01:44 +08:00
|
|
|
|
installPassiveResourceWatch(entry);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:51:16 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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()
|
|
|
|
|
|
: '';
|
2026-06-05 23:00:53 +08:00
|
|
|
|
if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
2026-06-04 18:51:16 +08:00
|
|
|
|
const requestedKind = normalizeResourceTabKind(input);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
2026-06-04 18:51:16 +08:00
|
|
|
|
await refreshExistingPdfResourceTab(existing, input);
|
|
|
|
|
|
refreshExistingOfficeResourceTab(existing, input);
|
|
|
|
|
|
applyEvidenceLocatorToEntry(existing, input);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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);
|
2026-06-04 23:00:53 +08:00
|
|
|
|
} else if (entry.kind === 'markdown' || entry.kind === 'text') {
|
2026-05-26 01:15:28 +08:00
|
|
|
|
await openTiptapResourceTab(entry, input);
|
2026-06-04 23:00:53 +08:00
|
|
|
|
} else if (entry.kind === 'code') {
|
|
|
|
|
|
await openReadonlyCodeResourceTab(entry, input);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
} else {
|
2026-06-04 18:51:16 +08:00
|
|
|
|
await openPassiveResourceTab(entry, input);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
}
|
2026-06-04 18:51:16 +08:00
|
|
|
|
applyEvidenceLocatorToEntry(entry, input);
|
2026-05-26 01:15:28 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 17:17:49 +08:00
|
|
|
|
try {
|
|
|
|
|
|
bindLocalOcrTopbarAction();
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
2026-05-26 01:15:28 +08:00
|
|
|
|
return {
|
|
|
|
|
|
activateMainEditorTab,
|
|
|
|
|
|
bindMainEditorPageTab,
|
|
|
|
|
|
buildOpenEditorsSnapshot,
|
|
|
|
|
|
closeResourceTabsForPane,
|
|
|
|
|
|
normalizePaneRole,
|
|
|
|
|
|
openResourceInActiveTab,
|
|
|
|
|
|
resolveResourceOpen,
|
|
|
|
|
|
syncResourceSessionTabGuards,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|