feat: add evidence search and stabilize pdf previews

- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes
- fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug
- keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable

Verification:
- node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js
- node scripts/task-dev-hot-plan-test.js
- cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar
- cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot
- cargo build -p mnote-web --manifest-path rust/Cargo.toml
- browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
This commit is contained in:
lix-2026
2026-06-04 18:51:16 +08:00
parent 9f4b5c4d48
commit 627213dc01
51 changed files with 15960 additions and 1844 deletions
@@ -784,6 +784,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
console.warn('mnote mindmap resource tab unmount failed', error);
}
}
releaseInlinePdfResource(entry);
if (entry.tab instanceof HTMLElement) entry.tab.remove();
if (entry.panel instanceof HTMLElement) entry.panel.remove();
resourceTabRegistry.delete(key);
@@ -883,6 +884,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const releaseResourceTabEntryRuntime = (entry) => {
if (!entry) return;
releaseInlinePdfResource(entry);
if (entry.view) {
unmountEditorViewBinding(entry.view, { releaseSession: true });
entry.view = null;
@@ -926,6 +928,144 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return url.toString();
};
const normalizeEvidenceBBox = (value) => {
if (!value) return null;
if (Array.isArray(value) && value.length >= 4) {
const values = value.slice(0, 4).map((item) => Number(item));
return values.every(Number.isFinite) ? { x0: values[0], y0: values[1], x1: values[2], y1: values[3] } : null;
}
if (typeof value === 'object') {
const bbox = {
x0: Number(value.x0),
y0: Number(value.y0),
x1: Number(value.x1),
y1: Number(value.y1),
};
return Object.values(bbox).every(Number.isFinite) ? bbox : null;
}
if (typeof value === 'string') {
const parts = value.split(',').map((item) => Number(item.trim()));
return parts.length >= 4 && parts.slice(0, 4).every(Number.isFinite)
? { x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }
: null;
}
return null;
};
const normalizeEvidenceLocatorInput = (input = {}) => {
const locator = input.evidenceLocator && typeof input.evidenceLocator === 'object'
? input.evidenceLocator
: input.locator && typeof input.locator === 'object'
? input.locator
: null;
const params = locator?.openAction?.params && typeof locator.openAction.params === 'object' ? locator.openAction.params : {};
const page = Number(input.page ?? locator?.page ?? params.page);
const bbox = normalizeEvidenceBBox(input.bbox ?? locator?.bbox ?? params.bbox);
const sourceMapPath = String(input.sourceMapPath || locator?.sourceMapPath || params.sourceMapPath || '').trim();
const blockId = String(input.blockId || locator?.blockId || params.blockId || '').trim();
const lineRange = input.lineRange || locator?.lineRange || params.lineRange || null;
const charRange = input.charRange || locator?.charRange || params.charRange || null;
if (!locator && !Number.isFinite(page) && !bbox && !sourceMapPath && !blockId && !lineRange && !charRange) return null;
return {
schema: 'mnote.evidence_locator.v1',
...(locator || {}),
page: Number.isFinite(page) ? page : null,
bbox,
sourceMapPath,
blockId,
lineRange,
charRange,
};
};
const evidenceBBoxParam = (bbox) => {
const normalized = normalizeEvidenceBBox(bbox);
return normalized ? [normalized.x0, normalized.y0, normalized.x1, normalized.y1].join(',') : '';
};
const applyEvidenceLocatorToFrame = (frame, locator) => {
if (!(frame instanceof HTMLIFrameElement) || !locator) return;
try {
const url = new URL(frame.getAttribute('src') || frame.src || '', window.location.origin);
if (Number.isFinite(Number(locator.page))) url.searchParams.set('page', String(Number(locator.page)));
const bbox = evidenceBBoxParam(locator.bbox);
if (bbox) url.searchParams.set('bbox', bbox);
if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath));
if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId));
frame.src = url.toString();
} catch (_) {}
};
const applyEvidenceLocatorToImagePanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
const bbox = normalizeEvidenceBBox(locator.bbox);
let overlay = entry.panel.querySelector('[data-mnote-evidence-bbox-highlight]');
if (!bbox) {
if (overlay instanceof HTMLElement) overlay.hidden = true;
return;
}
if (!(overlay instanceof HTMLElement)) {
overlay = document.createElement('div');
overlay.className = 'mnote-resource-tab-bbox-highlight';
overlay.setAttribute('data-mnote-evidence-bbox-highlight', 'true');
entry.panel.append(overlay);
}
overlay.hidden = false;
overlay.style.left = `${Math.max(0, bbox.x0)}px`;
overlay.style.top = `${Math.max(0, bbox.y0)}px`;
overlay.style.width = `${Math.max(1, bbox.x1 - bbox.x0)}px`;
overlay.style.height = `${Math.max(1, bbox.y1 - bbox.y0)}px`;
};
const applyEvidenceLocatorToTextPanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator?.blockId) return;
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
if (!(root instanceof HTMLElement)) return;
const selector = `[data-block-id="${cssSafe(locator.blockId)}"]`;
const target = root.querySelector(selector);
if (!(target instanceof HTMLElement)) return;
root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight');
});
target.setAttribute('data-mnote-evidence-text-highlight', 'true');
target.scrollIntoView({ block: 'center', inline: 'nearest' });
};
const applyEvidenceLocatorToInlinePdfPanel = (entry, locator) => {
if (!(entry?.panel instanceof HTMLElement) || !locator) return;
const pageNumber = Number(locator.page || 0);
if (!Number.isFinite(pageNumber) || pageNumber <= 0) return;
const canvas = entry.panel.querySelector(`canvas.mnote-pdf-page[data-page-number="${pageNumber}"]`);
if (!(canvas instanceof HTMLCanvasElement)) return;
entry.panel.querySelectorAll('canvas.mnote-pdf-page[data-mnote-evidence-page="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-page');
});
canvas.setAttribute('data-mnote-evidence-page', 'true');
canvas.scrollIntoView({ block: 'center', inline: 'nearest' });
};
const applyEvidenceLocatorToEntry = (entry, input = {}) => {
if (!(entry?.panel instanceof HTMLElement)) return;
const locator = normalizeEvidenceLocatorInput(input);
if (!locator) return;
entry.evidenceLocator = locator;
entry.panel.setAttribute('data-mnote-evidence-locator', JSON.stringify(locator));
entry.panel.setAttribute('data-mnote-evidence-open', 'true');
if (Number.isFinite(Number(locator.page))) entry.panel.setAttribute('data-mnote-evidence-page', String(Number(locator.page)));
if (locator.blockId) entry.panel.setAttribute('data-mnote-evidence-block-id', String(locator.blockId));
if (locator.sourceMapPath) entry.panel.setAttribute('data-mnote-evidence-source-map-path', String(locator.sourceMapPath));
const bbox = evidenceBBoxParam(locator.bbox);
if (bbox) entry.panel.setAttribute('data-mnote-evidence-bbox', bbox);
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
if (frame instanceof HTMLIFrameElement) applyEvidenceLocatorToFrame(frame, locator);
if (entry.kind === 'image') applyEvidenceLocatorToImagePanel(entry, locator);
if (entry.kind === 'pdf') applyEvidenceLocatorToInlinePdfPanel(entry, locator);
if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 80);
window.setTimeout(() => applyEvidenceLocatorToTextPanel(entry, locator), 450);
}
};
const renderPassiveResourceMissing = (entry, message) => {
if (!(entry?.panel instanceof HTMLElement)) return;
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
@@ -945,6 +1085,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
if (frame instanceof HTMLIFrameElement) {
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
return;
}
if (entry.kind === 'pdf' && entry.inlinePdfSourceHref) {
void openPassiveResourceTab(entry, { ...(entry.lastPassiveInput || {}), href: withResourceReloadToken(entry.inlinePdfSourceHref) });
}
};
@@ -986,6 +1130,115 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
};
const releaseInlinePdfResource = (entry) => {
if (!entry) return;
const pdf = entry.inlinePdfDocument;
entry.inlinePdfDocument = null;
entry.inlinePdfRenderToken = null;
if (!pdf) return;
try {
void pdf.destroy();
} catch (_) {}
};
const pdfFileUrlFromPreviewHref = (href) => {
const value = String(href || '').trim();
if (!value) return '';
try {
const url = new URL(value, window.location.origin);
return String(url.searchParams.get('fileUrl') || value).trim();
} catch (_) {
return value;
}
};
const renderInlinePdfPage = async (entry, viewer, pdf, pageNumber, evidenceLocator) => {
const page = await pdf.getPage(pageNumber);
const baseViewport = page.getViewport({ scale: 1 });
const availableWidth = Math.max(280, viewer.clientWidth - 20);
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
const viewport = page.getViewport({ scale });
const outputScale = Math.min(2.5, Math.max(2, window.devicePixelRatio || 1));
const canvas = document.createElement('canvas');
canvas.className = 'mnote-pdf-page';
canvas.setAttribute('data-page-number', String(pageNumber));
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.display = 'block';
canvas.style.maxWidth = '100%';
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
canvas.style.margin = '0 auto 14px';
canvas.style.background = '#fff';
canvas.style.border = '1px solid #d8d8d2';
canvas.style.boxShadow = '0 2px 10px rgba(25, 25, 22, .08)';
const context = canvas.getContext('2d', { alpha: false });
if (!context) return;
await page.render({
canvasContext: context,
viewport,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null,
}).promise;
const evidencePage = Number(evidenceLocator?.page || 0);
if (evidencePage === pageNumber) {
canvas.setAttribute('data-mnote-evidence-page', 'true');
const bbox = normalizeEvidenceBBox(evidenceLocator?.bbox);
if (bbox) {
const rect = viewport.convertToViewportRectangle([bbox.x0, bbox.y0, bbox.x1, bbox.y1]);
const x = Math.min(rect[0], rect[2]);
const y = Math.min(rect[1], rect[3]);
const width = Math.max(1, Math.abs(rect[2] - rect[0]));
const height = Math.max(1, Math.abs(rect[3] - rect[1]));
context.save();
context.scale(outputScale, outputScale);
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
context.strokeStyle = 'rgba(37, 99, 235, 0.9)';
context.lineWidth = 2;
context.fillRect(x, y, width, height);
context.strokeRect(x, y, width, height);
context.restore();
}
}
if (entry.inlinePdfDocument === pdf) viewer.append(canvas);
if (evidencePage === pageNumber) window.setTimeout(() => canvas.scrollIntoView({ block: 'center', inline: 'nearest' }), 0);
};
const openInlinePdfResourceTab = async (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
const fileUrl = pdfFileUrlFromPreviewHref(href);
if (!(entry?.panel instanceof HTMLElement) || !fileUrl) return false;
releaseInlinePdfResource(entry);
const renderToken = {};
entry.passiveFrameSrc = href;
entry.inlinePdfSourceHref = href;
entry.inlinePdfRenderToken = renderToken;
entry.lastPassiveInput = { ...input };
entry.panel.replaceChildren();
const viewer = document.createElement('div');
viewer.className = 'mnote-pdf-viewer';
viewer.setAttribute('data-mnote-inline-pdf-viewer', 'true');
viewer.style.width = '100%';
viewer.style.maxWidth = '1180px';
viewer.style.margin = '0 auto';
viewer.style.padding = '8px 12px 28px';
entry.panel.append(viewer);
const pdfjsLib = await import('/api/pdfjs/pdf.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = '/api/pdfjs/pdf.worker.mjs';
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(window.location.origin);
const pdf = await pdfjsLib.getDocument({ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }).promise;
entry.inlinePdfDocument = pdf;
const total = Number(pdf.numPages || 0);
viewer.setAttribute('data-mnote-pdf-status', `0 / ${total}`);
const evidenceLocator = normalizeEvidenceLocatorInput(input);
for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {
if (entry.inlinePdfDocument !== pdf || entry.inlinePdfRenderToken !== renderToken) return true;
await renderInlinePdfPage(entry, viewer, pdf, pageNumber, evidenceLocator);
viewer.setAttribute('data-mnote-pdf-status', `${pageNumber} / ${total}`);
}
installPassiveResourceWatch(entry);
return true;
};
const isLocalOcrSourceEntry = (entry) => {
if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
@@ -1136,7 +1389,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (!(toggle instanceof HTMLButtonElement)) return null;
if (toggle.getAttribute('data-mnote-local-ocr-bound') === 'true') return toggle;
toggle.setAttribute('data-mnote-local-ocr-bound', 'true');
toggle.addEventListener('click', () => {
toggle.addEventListener('click', (event) => {
if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') {
event.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:open-local-ocr-settings'));
return;
}
void runManualLocalOcrForActiveTarget(toggle).catch((error) => {
console.warn('mnote local OCR 手动入口失败', error);
});
@@ -1187,10 +1445,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle';
toggle.setAttribute('title', 'OCR 任务');
toggle.setAttribute('aria-label', 'OCR 任务');
toggle.setAttribute('title', 'OCR 设置');
toggle.setAttribute('aria-label', 'OCR 设置');
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
toggle.setAttribute('data-mnote-action', 'toggle-ocr-tasks');
toggle.setAttribute('data-mnote-action', 'open-ocr-settings');
toggle.innerHTML = '<span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span>';
if (actions instanceof HTMLElement) actions.appendChild(toggle);
else document.body.appendChild(toggle);
@@ -1276,7 +1534,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
if (toggle instanceof HTMLButtonElement) {
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务` : 'OCR 任务');
const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置');
toggle.setAttribute('title', label);
toggle.setAttribute('aria-label', label);
toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
@@ -1595,6 +1853,22 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return jobs;
};
window.addEventListener('mnote:local-ocr-settings-action', (event) => {
const action = String(event?.detail?.action || '').trim();
if (action === 'run-active') {
const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
void runManualLocalOcrForActiveTarget(toggle instanceof HTMLButtonElement ? toggle : null).catch((error) => {
console.warn('mnote local OCR 设置入口识别失败', error);
});
return;
}
if (action === 'tasks') {
ensureLocalOcrTaskDock();
localOcrTaskState.drawerOpen = true;
renderLocalOcrTaskDock();
}
});
const renderLocalOcrToolbar = (entry) => {
if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]');
@@ -1765,15 +2039,16 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
};
const openPassiveResourceTab = (entry, input) => {
const openPassiveResourceTab = async (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
if (entry.kind === 'image') {
entry.panel.innerHTML = '<img class="mnote-resource-tab-image" alt="">';
entry.panel.innerHTML = '<div class="mnote-resource-tab-image-shell"><img class="mnote-resource-tab-image" alt=""><div class="mnote-resource-tab-bbox-highlight" data-mnote-evidence-bbox-highlight="true" hidden></div></div>';
const img = entry.panel.querySelector('img');
if (img instanceof HTMLImageElement) {
img.src = href;
img.alt = entry.title;
}
applyEvidenceLocatorToEntry(entry, input);
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
@@ -1781,6 +2056,18 @@ export const createResourceTabRuntime = (dependencies = {}) => {
installPassiveResourceWatch(entry);
return;
}
if (entry.kind === 'pdf') {
await openInlinePdfResourceTab(entry, input);
applyEvidenceLocatorToEntry(entry, input);
if (isLocalOcrSourceEntry(entry)) {
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void maybeAutoCreateLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 自动任务失败', error));
}
installPassiveResourceWatch(entry);
return;
}
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
const frame = entry.panel.querySelector('iframe');
if (frame instanceof HTMLIFrameElement) {
@@ -1792,7 +2079,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}, { once: true });
}
frame.src = href;
entry.passiveFrameSrc = href;
}
applyEvidenceLocatorToEntry(entry, input);
if (isLocalOcrSourceEntry(entry)) {
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
@@ -1802,6 +2091,16 @@ export const createResourceTabRuntime = (dependencies = {}) => {
installPassiveResourceWatch(entry);
};
const refreshExistingPdfResourceTab = async (entry, input) => {
if (!entry || entry.kind !== 'pdf') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
if (!nextHref) return false;
if (nextHref !== String(entry.inlinePdfSourceHref || entry.passiveFrameSrc || '').trim()) {
await openPassiveResourceTab(entry, input);
}
return true;
};
const refreshExistingOfficeResourceTab = (entry, input) => {
if (!entry || entry.kind !== 'office') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
@@ -1810,7 +2109,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const currentHref = frame instanceof HTMLIFrameElement
? String(frame.getAttribute('src') || frame.src || '').trim()
: '';
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);
return true;
};
@@ -1883,6 +2182,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
if (!objectIdentity) return false;
const registryKey = resourceTabRegistryKey(paneRole, objectIdentity);
const requestedKind = normalizeResourceTabKind(input);
if (paneRole === 'secondary') {
resourceTabRegistry.forEach((entry, key) => {
if (normalizePaneRole(entry.paneRole) !== paneRole) return;
@@ -1896,8 +2196,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
const existing = resourceTabRegistry.get(registryKey);
if (existing) {
refreshExistingOfficeResourceTab(existing, input);
activateMainEditorTab(registryKey, paneRole);
await refreshExistingPdfResourceTab(existing, input);
refreshExistingOfficeResourceTab(existing, input);
applyEvidenceLocatorToEntry(existing, input);
return true;
}
const entry = createResourceTabDom({ ...input, objectIdentity, paneRole });
@@ -1910,8 +2212,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
} else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
await openTiptapResourceTab(entry, input);
} else {
openPassiveResourceTab(entry, input);
await openPassiveResourceTab(entry, input);
}
applyEvidenceLocatorToEntry(entry, input);
activateMainEditorTab(registryKey, paneRole);
return true;
} catch (error) {