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
+227 -19
View File
@@ -10,7 +10,7 @@ use crate::routes::documents::{
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_children_snapshot_with_reveal,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
};
@@ -875,6 +875,18 @@ fn resolve_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
Some(resolved)
}
fn resolve_lazy_runtime_asset_path(asset_path: &str) -> Option<PathBuf> {
let asset_path = asset_path.trim();
if asset_path != "tiptap_mindmap_paragraph_runtime.js" {
return None;
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../reference-code/leptos-tiptap/src/js/generated")
.join(asset_path),
)
}
fn runtime_asset_content_type(asset_path: &str) -> &'static str {
if asset_path.ends_with(".wasm") {
"application/wasm"
@@ -952,8 +964,16 @@ pub async fn editor_image_placeholder_asset() -> Response {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfPreviewQuery {
#[serde(default, alias = "fileUrl")]
file_url: Option<String>,
#[serde(default, alias = "fileName")]
file_name: Option<String>,
#[serde(default)]
page: Option<u32>,
#[serde(default)]
bbox: Option<String>,
#[serde(default, alias = "blockId")]
block_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -1385,6 +1405,9 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("PDF 预览");
let target_page = query.page.unwrap_or_default();
let target_bbox = query.bbox.unwrap_or_default();
let target_block_id = query.block_id.unwrap_or_default();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -1406,6 +1429,7 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
html, body {{ margin: 0; min-height: 100%; background: var(--mnote-pdf-bg); color: var(--mnote-pdf-text); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.mnote-pdf-viewer {{ width: 100%; max-width: var(--mnote-preview-max-width); margin: 0 auto; padding: 8px 12px 28px; }}
.mnote-pdf-page {{ display: block; max-width: 100%; margin: 0 auto 14px; background: #fff; border: 1px solid var(--mnote-pdf-border); box-shadow: 0 2px 10px rgba(25, 25, 22, .08); }}
.mnote-pdf-page[data-mnote-evidence-page="true"] {{ outline: 2px solid #2563eb; outline-offset: 2px; }}
.mnote-pdf-message {{ max-width: 720px; margin: 24px auto; padding: 16px; border: 1px solid var(--mnote-pdf-border); border-radius: 8px; background: var(--mnote-pdf-panel); color: var(--mnote-pdf-muted); font-size: 14px; line-height: 1.6; }}
@media (max-width: 640px) {{
.mnote-pdf-viewer {{ padding: 0 6px 18px; }}
@@ -1413,7 +1437,7 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
}}
</style>
</head>
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf">
<body data-file-url="{file_url}" data-file-name="{file_name}" data-page-width-content-type="pdf" data-evidence-page="{target_page}" data-evidence-bbox="{target_bbox}" data-evidence-block-id="{target_block_id}">
<main class="mnote-pdf-viewer" id="mnote-pdf-viewer"></main>
<script type="module">
import * as pdfjsLib from '/api/pdfjs/pdf.mjs';
@@ -1422,6 +1446,17 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
const viewer = document.getElementById('mnote-pdf-viewer');
const fileUrl = body.dataset.fileUrl || '';
const pageWidthContentType = 'pdf';
const evidencePage = Number(body.dataset.evidencePage || 0);
const evidenceBBox = parseEvidenceBBox(body.dataset.evidenceBbox || '');
const activeRenderTasks = new Set();
let pdfDocument = null;
let disposed = false;
function parseEvidenceBBox(value) {{
const parts = String(value || '').split(',').map((item) => Number(item.trim()));
if (parts.length < 4 || parts.slice(0, 4).some((item) => !Number.isFinite(item))) return null;
return {{ x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }};
}}
function previewCssMaxWidth(mode) {{
if (mode === 'readable') return '760px';
@@ -1473,29 +1508,78 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
}}
async function renderPage(pdf, pageNumber) {{
if (disposed || !pdf || pdf !== pdfDocument) return;
const page = await pdf.getPage(pageNumber);
if (disposed || pdf !== pdfDocument) return;
const baseViewport = page.getViewport({{ scale: 1 }});
const availableWidth = Math.max(280, (viewer ? viewer.clientWidth : window.innerWidth) - 20);
const scale = Math.max(0.6, Math.min(2.4, availableWidth / baseViewport.width));
const viewport = page.getViewport({{ scale }});
const outputScale = Math.min(2, window.devicePixelRatio || 1);
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.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
const context = canvas.getContext('2d', {{ alpha: false }});
if (!context) return;
if (viewer) viewer.append(canvas);
await page.render({{
if (disposed || pdf !== pdfDocument) return;
const renderTask = page.render({{
canvasContext: context,
viewport,
transform: outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
}}).promise;
}});
activeRenderTasks.add(renderTask);
try {{
await renderTask.promise;
}} finally {{
activeRenderTasks.delete(renderTask);
}}
if (disposed || pdf !== pdfDocument) return;
if (viewer) viewer.append(canvas);
if (evidencePage === pageNumber) {{
canvas.setAttribute('data-mnote-evidence-page', 'true');
if (evidenceBBox) {{
const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.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();
}}
window.setTimeout(() => canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
}}
}}
function disposePreview() {{
disposed = true;
for (const task of Array.from(activeRenderTasks)) {{
try {{ task.cancel(); }} catch (_) {{}}
}}
activeRenderTasks.clear();
const doomedDocument = pdfDocument;
if (doomedDocument && typeof doomedDocument.destroy === 'function') {{
try {{ void doomedDocument.destroy(); }} catch (_) {{}}
}}
pdfDocument = null;
}}
window.__mnotePdfPreviewDispose = disposePreview;
window.addEventListener('pagehide', () => {{
void disposePreview();
}}, {{ once: true }});
async function main() {{
disposed = false;
if (!fileUrl) {{
setStatus('不可用');
showMessage('PDF 链接不可用');
@@ -1504,13 +1588,15 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
try {{
await loadPreviewWidthPreferences();
const sameOrigin = fileUrl.startsWith('/') || fileUrl.startsWith(location.origin);
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin }}).promise;
const pdf = await pdfjsLib.getDocument({{ url: fileUrl, withCredentials: sameOrigin, disableWorker: true }}).promise;
pdfDocument = pdf;
if (viewer) viewer.replaceChildren();
setStatus('0 / ' + pdf.numPages);
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {{
setStatus(pageNumber + ' / ' + pdf.numPages);
if (disposed || pdf !== pdfDocument) return;
await renderPage(pdf, pageNumber);
setStatus(pageNumber + ' / ' + pdf.numPages);
}}
setStatus(pdf.numPages + ' 页');
}} catch (error) {{
console.warn('[mnote pdf preview] render failed', error);
setStatus('打开失败');
@@ -1525,6 +1611,9 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
title = escape_html(file_name),
file_url = escape_html(&file_url),
file_name = escape_html(file_name),
target_page = target_page,
target_bbox = escape_html(&target_bbox),
target_block_id = escape_html(&target_block_id),
);
let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "pdf-preview");
@@ -2191,7 +2280,8 @@ pub async fn leptos_tiptap_manifest() -> Response {
"wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm",
"assetPaths": [
"mnote-leptos-tiptap-spike-island.js",
"mnote-leptos-tiptap-spike-island_bg.wasm"
"mnote-leptos-tiptap-spike-island_bg.wasm",
"tiptap_mindmap_paragraph_runtime.js"
],
"generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island"
});
@@ -2211,13 +2301,25 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
"leptos-tiptap runtime asset 路径非法",
));
};
let bytes = std::fs::read(&resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?;
let bytes = match std::fs::read(&resolved) {
Ok(bytes) => bytes,
Err(_) => {
let Some(lazy_resolved) = resolve_lazy_runtime_asset_path(&asset_path) else {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
));
};
std::fs::read(&lazy_resolved).map_err(|_| {
WebError::new(
StatusCode::NOT_FOUND,
"runtime_asset_not_found",
format!("leptos-tiptap runtime asset 不存在: {asset_path}"),
)
})?
}
};
let response = Response::builder()
.status(StatusCode::OK)
.header(
@@ -2712,7 +2814,11 @@ pub(crate) fn render_local_file_tree_html_scoped(
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_file_tree_children_snapshot(root_uri, scope)?
load_local_folder_file_tree_children_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
@@ -2755,6 +2861,7 @@ mod tests {
include_str!("../../browser/document-slash-position-runtime.js");
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
include_str!("../../browser/sidebar-page-settings-runtime.js");
const SIDEBAR_TREE_RUNTIME_JS: &str = include_str!("../../browser/sidebar-tree-runtime.js");
const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str =
include_str!("../../browser/document-tiptap-conversion-runtime.js");
@@ -3249,6 +3356,14 @@ mod tests {
));
assert!(resource_runtime.contains("/api/local-folder/resource/read"));
assert!(resource_runtime.contains("/api/local-folder/resource/write"));
assert!(resource_runtime.contains("normalizeEvidenceLocatorInput"));
assert!(resource_runtime.contains("applyEvidenceLocatorToEntry"));
assert!(resource_runtime.contains("data-mnote-evidence-bbox"));
assert!(resource_runtime.contains("data-mnote-evidence-text-highlight"));
assert!(runtime.contains("applyDocumentEvidenceLocatorFromUrl"));
let sidebar_runtime = SIDEBAR_TREE_RUNTIME_JS;
assert!(sidebar_runtime.contains("openEvidenceSearchResult"));
assert!(sidebar_runtime.contains("data-evidence-locator"));
let mindmap_runtime = DOCUMENT_MINDMAP_HOST_RUNTIME_JS;
assert!(mindmap_runtime.contains("replacePrimaryPaneMindmap"));
assert!(mindmap_runtime.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
@@ -3313,12 +3428,16 @@ mod tests {
assert!(resource_runtime.contains("const nextKey = lastActiveResourceTabKey(role);"));
assert!(resource_runtime.contains("if (nextTab instanceof HTMLElement) nextTab.focus();"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("openInlinePdfResourceTab"));
assert!(resource_runtime.contains("data-mnote-inline-pdf-viewer"));
assert!(resource_runtime.contains("refreshExistingPdfResourceTab(existing, input);"));
assert!(resource_runtime.contains("releaseInlinePdfResource"));
assert!(
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime
.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);"));
.contains("if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);"));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
@@ -3526,6 +3645,27 @@ mod tests {
);
}
#[tokio::test]
async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(js.contains("simple-mind-map"));
assert!(js.contains("createMindmapParagraphNodeView"));
}
#[tokio::test]
async fn leptos_tiptap_runtime_assets_are_cacheable() {
let manifest_response = app()
@@ -4338,6 +4478,74 @@ mod tests {
);
}
#[tokio::test]
async fn document_shell_local_folder_filetree_scope_reveals_active_file_parent_chain() {
let root = std::env::temp_dir().join(format!(
"mnote-local-document-shell-scoped-filetree-reveal-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("design").join("07-ai").join("done"))
.expect("create design done");
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
.expect("create unrelated scope sibling");
std::fs::write(
root.join("design")
.join("07-ai")
.join("done")
.join("Target.md"),
"# Target\n",
)
.expect("write target");
std::fs::write(
root.join("design")
.join("05-editor-mainline")
.join("Other.md"),
"# Other\n",
)
.expect("write unrelated page");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "user_test");
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:design~2F07-ai~2Fdone~2FTarget.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
))
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(
html.contains(r#"data-local-relative-path="design/07-ai""#),
"scoped FileTree 应保留 active 文档父级 07-ai"
);
assert!(
html.contains(r#"data-local-relative-path="design/07-ai/done""#),
"scoped FileTree 应只 reveal active 文档命中的 done 父链"
);
assert!(
html.contains(r#"data-local-relative-path="design/07-ai/done/Target.md""#),
"active Markdown 文件应在 scoped FileTree 首屏可见"
);
assert!(
!html.contains(r#"data-local-relative-path="design/05-editor-mainline/Other.md""#),
"不相关 sibling 目录不应被 reveal 扫入 scoped FileTree"
);
}
#[test]
fn document_conflict_panel_runtime_contains_dom_helpers() {
const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str =