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:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user