diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd8a66f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + frontend: + name: Frontend + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.24.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Build + run: pnpm build + + - name: Audit + run: pnpm audit --audit-level high + + rust: + name: Rust + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Tauri system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libwebkit2gtk-4.1-dev \ + patchelf + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy,rustfmt + + - name: Check formatting + working-directory: src-tauri + run: cargo fmt --all --check + + - name: Clippy + working-directory: src-tauri + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Test + working-directory: src-tauri + run: cargo test --all diff --git a/package.json b/package.json index 140427d..2e6ff1c 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "tauri:build": "tauri build", "build": "tsc -b && vite build", "lint": "eslint .", + "audit": "pnpm audit --audit-level high", "preview": "vite preview" }, "dependencies": { "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", "docx": "^9.7.1", + "dompurify": "^3.4.8", "jspdf": "^4.2.1", "lucide-react": "^1.17.0", "marked": "^18.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d8b8ff..9111ac3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: docx: specifier: ^9.7.1 version: 9.7.1 + dompurify: + specifier: ^3.4.8 + version: 3.4.8 jspdf: specifier: ^4.2.1 version: 4.2.1 @@ -274,42 +277,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.2': resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} @@ -363,35 +360,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.2': resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.2': resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.2': resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} @@ -607,8 +599,8 @@ packages: resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==} engines: {node: '>=10'} - dompurify@3.4.7: - resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} + dompurify@3.4.8: + resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -851,21 +843,18 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} @@ -1700,10 +1689,9 @@ snapshots: xml: 1.0.1 xml-js: 1.6.11 - dompurify@3.4.7: + dompurify@3.4.8: optionalDependencies: '@types/trusted-types': 2.0.7 - optional: true electron-to-chromium@1.5.364: {} @@ -1894,7 +1882,7 @@ snapshots: optionalDependencies: canvg: 3.0.11 core-js: 3.49.0 - dompurify: 3.4.7 + dompurify: 3.4.8 html2canvas: 1.4.1 jszip@3.10.1: diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 9d39928..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,3 @@ fn main() { tauri_build::build() } - diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ae99d77..0814a3d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, fs, - io::Write, path::{Path, PathBuf}, process::Command, + sync::{Mutex, OnceLock}, time::UNIX_EPOCH, }; +static WORKSPACE_ROOTS: OnceLock>> = OnceLock::new(); + #[derive(Debug, Serialize)] struct ArticleSummary { path: String, @@ -96,6 +98,7 @@ struct InsertImageAssetResponse { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] struct ReaderSettings { default_workspace: String, default_read_mode: String, @@ -121,6 +124,7 @@ impl Default for ReaderSettings { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] struct ReaderState { recent_workspaces: Vec, recent_files: Vec, @@ -129,6 +133,7 @@ struct ReaderState { reading_positions: HashMap, last_workspace: String, last_file: String, + last_read_mode: String, focus_mode: bool, settings: ReaderSettings, } @@ -143,6 +148,7 @@ impl Default for ReaderState { reading_positions: HashMap::new(), last_workspace: String::new(), last_file: String::new(), + last_read_mode: "desktop".to_string(), focus_mode: false, settings: ReaderSettings::default(), } @@ -156,7 +162,7 @@ fn initial_open_path() -> Option { #[tauri::command] fn scan_workspace(workspace: String) -> Result, String> { - let input = PathBuf::from(workspace); + let input = fs::canonicalize(PathBuf::from(workspace)).map_err(to_err)?; if input.is_file() { if !is_markdown_file(&input) { return Err("请选择 Markdown 文件。".to_string()); @@ -169,10 +175,11 @@ fn scan_workspace(workspace: String) -> Result, String> { } let root = input; + register_workspace_root(&root)?; let mut articles = Vec::new(); collect_markdown_files(&root, "文档", "document", true, &root, &mut articles)?; - articles.sort_by(|a, b| b.updated.cmp(&a.updated)); + articles.sort_by_key(|article| std::cmp::Reverse(article.updated)); Ok(articles) } @@ -191,7 +198,10 @@ fn search_workspace(request: SearchWorkspaceRequest) -> Result let lower_title = article.title.to_lowercase(); let lower_file = article.file_name.to_lowercase(); let lower_raw = raw.to_lowercase(); - if !lower_title.contains(&query) && !lower_file.contains(&query) && !lower_raw.contains(&query) { + if !lower_title.contains(&query) + && !lower_file.contains(&query) + && !lower_raw.contains(&query) + { continue; } @@ -238,14 +248,18 @@ fn search_workspace(request: SearchWorkspaceRequest) -> Result }); } - results.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.relative_path.cmp(&b.relative_path))); + results.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.relative_path.cmp(&b.relative_path)) + }); results.truncate(80); Ok(results) } #[tauri::command] fn read_article(path: String) -> Result { - let article_path = PathBuf::from(&path); + let article_path = scoped_markdown_path(&path)?; let content = fs::read_to_string(&article_path).map_err(to_err)?; let base_dir = article_path .parent() @@ -255,7 +269,7 @@ fn read_article(path: String) -> Result { let missing_images = find_missing_images(&content, &base_dir)?; Ok(ArticlePayload { - path, + path: article_path.to_string_lossy().to_string(), base_dir: base_dir.to_string_lossy().to_string(), content, preview_content, @@ -265,10 +279,10 @@ fn read_article(path: String) -> Result { #[tauri::command] fn save_article(request: SaveArticleRequest) -> Result { - let article_path = PathBuf::from(&request.path); + let article_path = scoped_markdown_path(&request.path)?; backup_article(&article_path)?; fs::write(&article_path, request.content).map_err(to_err)?; - read_article(request.path) + read_article(article_path.to_string_lossy().to_string()) } #[tauri::command] @@ -296,7 +310,7 @@ fn save_reader_state(mut state: ReaderState) -> Result<(), String> { #[tauri::command] fn preview_markdown_content(request: PreviewMarkdownRequest) -> Result { - let article_path = PathBuf::from(&request.article_path); + let article_path = scoped_markdown_path(&request.article_path)?; let base_dir = article_path .parent() .ok_or_else(|| "无法识别文章目录".to_string())? @@ -305,11 +319,10 @@ fn preview_markdown_content(request: PreviewMarkdownRequest) -> Result Result { - let article = PathBuf::from(&request.article_path); - if !is_markdown_file(&article) { - return Err("请先打开一个 Markdown 文件。".to_string()); - } +fn insert_image_asset( + request: InsertImageAssetRequest, +) -> Result { + let article = scoped_markdown_path(&request.article_path)?; let source = PathBuf::from(&request.image_path); if !source.is_file() { @@ -321,7 +334,10 @@ fn insert_image_asset(request: InsertImageAssetRequest) -> Result Result Result<(), String> { - let log_path = project_root().join("docs").join("BUILD_LOG.md"); - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent).map_err(to_err)?; - } - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .map_err(to_err)? - .write_all(format!("\n{}\n", entry).as_bytes()) - .map_err(to_err)?; - Ok(()) -} - #[tauri::command] fn save_reading_html(article_path: String, html: String) -> Result { - let article = PathBuf::from(article_path); + let article = scoped_markdown_path(&article_path)?; let slug = article .file_stem() .and_then(|name| name.to_str()) @@ -402,12 +402,6 @@ fn save_reading_html(article_path: String, html: String) -> Result Result { - let bytes = fs::read(PathBuf::from(path)).map_err(to_err)?; - Ok(general_purpose::STANDARD.encode(bytes)) -} - #[tauri::command] fn save_binary_export(request: SaveBinaryExportRequest) -> Result { let extension = request @@ -418,7 +412,7 @@ fn save_binary_export(request: SaveBinaryExportRequest) -> Result Result { let full = caps.get(0).map(|m| m.as_str()).unwrap_or_default(); let alt = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); let src = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); - if src.starts_with("data:image/") || src.starts_with("http://") || src.starts_with("https://") { + if src.starts_with("data:image/") + || src.starts_with("http://") + || src.starts_with("https://") + { return full.to_string(); } - let image_path = base_dir.join(src.replace('/', std::path::MAIN_SEPARATOR_STR)); + let Ok(image_path) = scoped_related_path(base_dir, src, true) else { + return full.to_string(); + }; match fs::read(&image_path) { Ok(bytes) => { let mime = mime_type_for(&bytes, &image_path); @@ -522,12 +519,23 @@ fn find_missing_images(raw: &str, base_dir: &Path) -> Result, let image_re = Regex::new(r#"!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)"#).map_err(to_err)?; let mut missing = Vec::new(); for caps in image_re.captures_iter(raw) { - let alt = caps.get(1).map(|m| m.as_str()).unwrap_or_default().to_string(); - let src = caps.get(2).map(|m| m.as_str()).unwrap_or_default().to_string(); - if src.starts_with("data:image/") || src.starts_with("http://") || src.starts_with("https://") { + let alt = caps + .get(1) + .map(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + let src = caps + .get(2) + .map(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + if src.starts_with("data:image/") + || src.starts_with("http://") + || src.starts_with("https://") + { continue; } - let image_path = base_dir.join(src.replace('/', std::path::MAIN_SEPARATOR_STR)); + let image_path = scoped_related_path(base_dir, &src, false)?; if !image_path.exists() { missing.push(MissingImage { alt, @@ -575,7 +583,104 @@ fn reader_state_path() -> Result { Ok(base.join("Markdown Reader").join("reader-state-v2.json")) } +fn workspace_registry() -> &'static Mutex> { + WORKSPACE_ROOTS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn register_workspace_root(root: &Path) -> Result<(), String> { + let root = fs::canonicalize(root).map_err(to_err)?; + if !root.is_dir() { + return Err("请选择 Markdown 文件夹。".to_string()); + } + workspace_registry().lock().map_err(to_err)?.insert(root); + Ok(()) +} + +fn scoped_markdown_path(path: &str) -> Result { + let article_path = scoped_existing_file(path)?; + if !is_markdown_file(&article_path) { + return Err("请先打开一个 Markdown 文件。".to_string()); + } + Ok(article_path) +} + +fn scoped_existing_file(path: &str) -> Result { + let file_path = fs::canonicalize(PathBuf::from(path)).map_err(to_err)?; + if !file_path.is_file() { + return Err("请选择有效文件。".to_string()); + } + ensure_path_in_registered_workspace(&file_path)?; + Ok(file_path) +} + +fn ensure_path_in_registered_workspace(path: &Path) -> Result { + let canonical = if path.exists() { + fs::canonicalize(path).map_err(to_err)? + } else { + normalize_path(path) + }; + let roots = workspace_registry().lock().map_err(to_err)?; + roots + .iter() + .filter(|root| canonical.starts_with(root)) + .max_by_key(|root| root.components().count()) + .cloned() + .ok_or_else(|| "路径不在当前已打开的 Markdown 工作区内。".to_string()) +} + +fn scoped_related_path(base_dir: &Path, src: &str, must_exist: bool) -> Result { + if src.starts_with("data:image/") || src.starts_with("http://") || src.starts_with("https://") { + return Err("远程或 data 图片不需要本地解析。".to_string()); + } + let relative = Path::new(src); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + std::path::Component::Prefix(_) | std::path::Component::RootDir + ) + }) + { + return Err("图片路径必须是当前文档内的相对路径。".to_string()); + } + let base_dir = fs::canonicalize(base_dir).map_err(to_err)?; + ensure_path_in_registered_workspace(&base_dir)?; + let normalized = + normalize_path(&base_dir.join(src.replace('/', std::path::MAIN_SEPARATOR_STR))); + ensure_path_in_registered_workspace(&normalized)?; + if must_exist { + let existing = fs::canonicalize(&normalized).map_err(to_err)?; + ensure_path_in_registered_workspace(&existing)?; + Ok(existing) + } else { + Ok(normalized) + } +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + fn trim_reader_state(state: &mut ReaderState) { + if !matches!( + state.settings.default_read_mode.as_str(), + "desktop" | "source" | "edit" + ) { + state.settings.default_read_mode = "desktop".to_string(); + } + if !matches!(state.last_read_mode.as_str(), "desktop" | "source" | "edit") { + state.last_read_mode = state.settings.default_read_mode.clone(); + } dedupe_trim(&mut state.recent_workspaces, 20); dedupe_trim(&mut state.recent_files, 50); dedupe_trim(&mut state.favorites, 500); @@ -587,7 +692,9 @@ fn trim_reader_state(state: &mut ReaderState) { .chain(state.pinned.iter()) .cloned() .collect(); - state.reading_positions.retain(|path, _| known_files.contains(path) || Path::new(path).exists()); + state + .reading_positions + .retain(|path, _| known_files.contains(path) || Path::new(path).exists()); } fn dedupe_trim(values: &mut Vec, max: usize) { @@ -629,7 +736,11 @@ fn mime_type_for(bytes: &[u8], path: &Path) -> &'static str { if bytes.starts_with(&[0x52, 0x49, 0x46, 0x46]) { return "image/webp"; } - match path.extension().and_then(|ext| ext.to_str()).unwrap_or_default() { + match path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + { "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", @@ -639,27 +750,7 @@ fn mime_type_for(bytes: &[u8], path: &Path) -> &'static str { } fn workspace_root_for_article(article: &Path) -> Result { - let mut current = article.parent(); - while let Some(path) = current { - if path.file_name().and_then(|name| name.to_str()) == Some("articles") { - return path - .parent() - .map(Path::to_path_buf) - .ok_or_else(|| "无法识别文章工作区".to_string()); - } - current = path.parent(); - } - article - .parent() - .map(Path::to_path_buf) - .ok_or_else(|| "无法识别文章工作区".to_string()) -} - -fn project_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR"))) - .to_path_buf() + ensure_path_in_registered_workspace(article) } fn initial_open_path_from_args(args: I) -> Option @@ -679,25 +770,27 @@ where fn open_path(path: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] - let status = { - let target = path.to_string_lossy().to_string(); - Command::new("cmd") - .args(["/C", "start", ""]) - .arg(target) - .status() - .map_err(to_err)? - }; + let status = Command::new("explorer") + .arg(path) + .status() + .map_err(to_err)?; #[cfg(target_os = "macos")] let status = Command::new("open").arg(path).status().map_err(to_err)?; #[cfg(all(unix, not(target_os = "macos")))] - let status = Command::new("xdg-open").arg(path).status().map_err(to_err)?; + let status = Command::new("xdg-open") + .arg(path) + .status() + .map_err(to_err)?; if status.success() { Ok(()) } else { - Err(format!("文件已生成,但打开失败:{}", path.to_string_lossy())) + Err(format!( + "文件已生成,但打开失败:{}", + path.to_string_lossy() + )) } } @@ -731,7 +824,12 @@ fn collect_markdown_files( Ok(()) } -fn summarize_markdown_file(path: &Path, group: &str, status: &str, root: &Path) -> Result { +fn summarize_markdown_file( + path: &Path, + group: &str, + status: &str, + root: &Path, +) -> Result { let raw = fs::read_to_string(path).unwrap_or_default(); let (title, digest) = parse_frontmatter(&raw); let metadata = fs::metadata(path).map_err(to_err)?; @@ -782,7 +880,15 @@ fn is_markdown_file(path: &Path) -> bool { } fn should_enter_dir(path: &Path) -> bool { - let ignored = [".git", "node_modules", "target", "dist", "dist-ssr"]; + let ignored = [ + ".git", + ".reader-backups", + "exports", + "node_modules", + "target", + "dist", + "dist-ssr", + ]; path.file_name() .and_then(|name| name.to_str()) .map(|name| !ignored.contains(&name)) @@ -840,7 +946,10 @@ mod tests { fn scans_markdown_workspace_recursively() { let root = fixture_root(); let articles = scan_workspace(root.to_string_lossy().to_string()).expect("scan workspace"); - assert!(!articles.is_empty(), "default workspace should contain markdown articles"); + assert!( + !articles.is_empty(), + "default workspace should contain markdown articles" + ); assert!( articles.iter().all(|article| article.status == "document"), "V2 scans folders as a generic document library" @@ -853,7 +962,10 @@ mod tests { let articles = scan_workspace(root.to_string_lossy().to_string()).expect("scan workspace"); let first = articles.first().expect("at least one article"); let payload = read_article(first.path.clone()).expect("read article"); - assert!(!payload.content.trim().is_empty(), "article content should not be empty"); + assert!( + !payload.content.trim().is_empty(), + "article content should not be empty" + ); assert!( !payload.preview_content.trim().is_empty(), "preview content should not be empty" @@ -863,7 +975,11 @@ mod tests { #[test] fn accepts_articles_subdirectory_as_workspace_input() { let root = fixture_root(); - let drafts_dir = root.join("articles").join("drafts").to_string_lossy().to_string(); + let drafts_dir = root + .join("articles") + .join("drafts") + .to_string_lossy() + .to_string(); let articles = scan_workspace(drafts_dir).expect("scan drafts dir"); assert!( !articles.is_empty(), @@ -944,6 +1060,7 @@ mod tests { let image_path = root.join("source image.png"); fs::write(&article_path, "# Note").expect("write article"); fs::write(&image_path, [0x89, 0x50, 0x4e, 0x47]).expect("write image"); + scan_workspace(root.to_string_lossy().to_string()).expect("register root"); let inserted = insert_image_asset(InsertImageAssetRequest { article_path: article_path.to_string_lossy().to_string(), @@ -952,7 +1069,10 @@ mod tests { .expect("insert image"); assert_eq!(inserted.relative_path, "note-assets/source-image.png"); - assert_eq!(inserted.markdown, "![source image](note-assets/source-image.png)"); + assert_eq!( + inserted.markdown, + "![source image](note-assets/source-image.png)" + ); assert!(root.join("note-assets").join("source-image.png").exists()); } @@ -970,6 +1090,7 @@ mod tests { let article_path = root.join("note.md"); fs::write(&article_path, "# Note").expect("write article"); fs::write(assets.join("image.png"), [0x89, 0x50, 0x4e, 0x47]).expect("write image"); + scan_workspace(root.to_string_lossy().to_string()).expect("register root"); let preview = preview_markdown_content(PreviewMarkdownRequest { article_path: article_path.to_string_lossy().to_string(), @@ -979,4 +1100,51 @@ mod tests { assert!(preview.contains("data:image/png;base64,")); } + + #[test] + fn uses_registered_workspace_root_for_nested_exports() { + let root = fixture_root(); + let nested_workspace = root.join("articles").join("drafts"); + let article_path = nested_workspace.join("sample.md"); + scan_workspace(nested_workspace.to_string_lossy().to_string()) + .expect("register nested root"); + + let export_root = workspace_root_for_article(&article_path).expect("resolve export root"); + + assert_eq!( + export_root, + fs::canonicalize(nested_workspace).expect("canonical nested root") + ); + } + + #[test] + fn rejects_article_reads_outside_registered_workspace() { + let root = std::env::temp_dir().join(format!( + "tauri-reader-scoped-root-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let outside = std::env::temp_dir().join(format!( + "tauri-reader-scoped-outside-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + fs::create_dir_all(&root).expect("create root"); + fs::create_dir_all(&outside).expect("create outside"); + fs::write(root.join("note.md"), "# Note").expect("write note"); + let outside_article = outside.join("outside.md"); + fs::write(&outside_article, "# Outside").expect("write outside"); + scan_workspace(root.to_string_lossy().to_string()).expect("register root"); + + let result = read_article(outside_article.to_string_lossy().to_string()); + + assert!( + result.is_err(), + "unregistered markdown path should be rejected" + ); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d38fe4e..6137713 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "0.2.0", "identifier": "local.markdown.reader", "build": { - "beforeDevCommand": "npm run dev", + "beforeDevCommand": "pnpm dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run build", + "beforeBuildCommand": "pnpm build", "frontendDist": "../dist" }, "app": { @@ -21,7 +21,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src ipc: http://ipc.localhost; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'" } }, "bundle": { diff --git a/src/App.css b/src/App.css index 2863674..af10948 100644 --- a/src/App.css +++ b/src/App.css @@ -348,6 +348,90 @@ button:hover:not(:disabled) { box-shadow: none; } +.article-style-codex { + --md-font: Aptos, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #2f6f4e; + --md-body-size: 16px; + --md-line-height: 1.88; +} + +.article-style-clean { + --md-font: Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #1f2933; + --md-body-size: 16px; + --md-line-height: 1.76; +} + +.article-style-serif { + --md-font: Georgia, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #355c7d; + --md-body-size: 17px; + --md-line-height: 1.94; +} + +.article-style-song { + --md-font: SimSun, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #374151; + --md-body-size: 16px; + --md-line-height: 2; +} + +.article-style-hei { + --md-font: SimHei, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #2563eb; + --md-body-size: 16px; + --md-line-height: 1.82; +} + +.article-style-yahei { + --md-font: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #1f7a4b; + --md-body-size: 16px; + --md-line-height: 1.88; +} + +.article-style-kai { + --md-font: KaiTi, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #9a650f; + --md-body-size: 17px; + --md-line-height: 2.06; +} + +.article-style-mono { + --md-font: Consolas, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #4b5563; + --md-body-size: 15px; + --md-line-height: 1.76; +} + +.article-style-report { + --md-font: DengXian, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #0f766e; + --md-body-size: 16px; + --md-line-height: 1.88; +} + +.article-style-book { + --md-font: FangSong, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #6b4f3a; + --md-body-size: 17px; + --md-line-height: 2.12; +} + +.article-style-compact { + --md-font: Arial, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #475569; + --md-body-size: 14px; + --md-line-height: 1.55; +} + +.article-style-presentation { + --md-font: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --md-accent: #7c3aed; + --md-body-size: 17px; + --md-line-height: 2; +} + .article-title { margin-bottom: 24px; padding-bottom: 18px; @@ -705,6 +789,23 @@ button:hover:not(:disabled) { white-space: nowrap; } +.outline-level-2 { + padding-left: 22px; +} + +.outline-level-3 { + padding-left: 34px; +} + +.outline-level-4 { + padding-left: 46px; +} + +.outline-level-5, +.outline-level-6 { + padding-left: 58px; +} + .export-panel { display: grid; align-content: start; diff --git a/src/App.tsx b/src/App.tsx index f73e11d..5ca6ee2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,10 @@ import { Component, useEffect, useMemo, useRef, useState } from 'react' -import type { CSSProperties, ErrorInfo, ReactNode, RefObject } from 'react' +import type { ErrorInfo, ReactNode, RefObject } from 'react' import { ArrowUp, Copy, Download, FileDown, - FileSearch, FileText, FolderOpen, Image as ImageIcon, @@ -22,17 +21,40 @@ import { Pin, RefreshCw, Save, - Search, Settings, - SlidersHorizontal, + Search, Star, X, } from 'lucide-react' import { invoke } from '@tauri-apps/api/core' import { open } from '@tauri-apps/plugin-dialog' import './App.css' +import { downloadBase64File, exportFileName, markdownToPlainText, readBundledPdfFont } from './exportHelpers' +import { + formatArticleCount, + formatExportSummary, + formatImageCount, + formatReadingMinutes, + formatWordCount, + type Language, + type UiText, + uiText, +} from './i18n' +import { LibrarySidebar } from './LibrarySidebar' +import { + demoArticles, + demoDefaultPayload, + demoPayloads, + extractImageSources, + getVisibleArticles, + groupArticlesByDisplayName, + highlightHtml, + searchDemoArticles, +} from './librarySearch' import { buildOutline, buildReadingHtml, getArticleStats, markdownToHtml, parseArticle } from './markdown' -import { getWordStylePreset, wordStylePresets } from './wordStyles' +import { QuickOpenDialog } from './QuickOpenDialog' +import { defaultReaderState, moveToFront, normalizeState, togglePath } from './readerState' +import { wordStylePresets } from './wordStyles' import type { ArticlePayload, ArticleStats, @@ -49,304 +71,11 @@ import type { WordStyleId, } from './types' -const demoArticle: ArticleSummary = { - path: 'demo.md', - file_name: 'demo.md', - title: 'Markdown Reader V2 示例', - digest: '本地阅读、全文搜索、收藏和导出放进同一个资料浏览工作台。', - group: '示例', - status: 'document', - updated: Math.floor(Date.now() / 1000), - relative_path: 'demo.md', -} - -const demoPayload: ArticlePayload = { - path: demoArticle.path, - base_dir: '', - missing_images: [], - content: `--- -title: Markdown Reader V2 示例 -digest: 本地阅读、全文搜索、收藏和导出放进同一个资料浏览工作台。 ---- - -## 阅读器定位 - -Markdown Reader V2 面向本地文档、项目 README、PRD、排障记录和技术方案。它优先解决快速回到上次工作区、搜索正文内容、沿着大纲阅读长文和轻量修改的问题。 - -## 全文搜索 - -搜索 SQL、Tauri、产品方案这类关键词时,结果不只看文件名,也会读取 Markdown 正文、frontmatter 和标题,并展示命中片段。 - -## 图片和代码 - -![系统预览](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwMCIgaGVpZ2h0PSI1NjAiIHZpZXdCb3g9IjAgMCAxMDAwIDU2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwMCIgaGVpZ2h0PSI1NjAiIGZpbGw9IiNmNmY4ZmEiLz48cmVjdCB4PSI2MCIgeT0iNjAiIHdpZHRoPSIyMDAiIGhlaWdodD0iNDQwIiByeD0iOCIgZmlsbD0iI2ZmZmZmZiIgc3Ryb2tlPSIjZTVlN2ViIi8+PHJlY3QgeD0iMzAwIiB5PSI2MCIgd2lkdGg9IjQyMCIgaGVpZ2h0PSI0NDAiIHJ4PSI4IiBmaWxsPSIjZmZmIiBzdHJva2U9IiNlNWU3ZWIiLz48cmVjdCB4PSI3NjAiIHk9IjYwIiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjQ0MCIgcng9IjgiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2U1ZTdlYiIvPjx0ZXh0IHg9IjMzMCIgeT0iMTUwIiBmb250LXNpemU9IjM2IiBmb250LWZhbWlseT0iQXJpYWwiIGZpbGw9IiMxZjI5MzMiPkxvY2FsIE1hcmtkb3duIFJlYWRpbmc8L3RleHQ+PHRleHQgeD0iMzMwIiB5PSIyMjAiIGZvbnQtc2l6ZT0iMjAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZmlsbD0iIzYyNzA3YSI+T3V0bGluZSwgc2VhcmNoLCByZWNlbnQgZmlsZXMsIGFuZCBleHBvcnQuPC90ZXh0Pjwvc3ZnPg==) - -\`\`\`ts -const result = await searchWorkspace('SQL') -await openDocument(result.path) -\`\`\` - -## 轻编辑 - -编辑模式保留 Markdown 源码、保存快捷键、保存前备份和本地图片插入。阅读器的主心智仍然是看文档,不把编辑器做重。 -`, - preview_content: '', -} -demoPayload.preview_content = demoPayload.content - interface InsertImageAssetResponse { markdown: string relativePath: string } -const defaultSettings: ReaderSettings = { - default_workspace: '', - default_read_mode: 'desktop', - default_export_style: 'codex', - restore_last_document: true, - remember_scroll_position: true, - focus_keep_outline: true, - language: 'zh', -} - -const defaultReaderState: ReaderState = { - recent_workspaces: [], - recent_files: [], - favorites: [], - pinned: [], - reading_positions: {}, - last_workspace: '', - last_file: '', - focus_mode: false, - settings: defaultSettings, -} - -const uiText = { - zh: { - brandSubtitle: '本地 Markdown 阅读器', - languageToggleAria: '界面语言', - switchLanguageTitle: '切换到 English', - workspaceAria: '工作区路径', - workspacePlaceholder: '选择或输入 Markdown 文件夹 / 文件', - collapseDocs: '收起文档库', - expandDocs: '展开文档库', - collapsePanel: '收起右栏', - expandPanel: '展开右栏', - focusMode: '专注模式', - exitFocus: '退出专注', - openFolder: '打开目录', - openMarkdownFile: '打开 Markdown 文件', - quickOpen: '快速打开', - refresh: '刷新', - saveMarkdown: '保存', - documents: '文档库', - searchPlaceholder: '搜索文件名、标题或正文', - noArticlesInPath: '当前路径没有找到 Markdown 文档。', - chooseWorkspaceOrFileFirst: '选择一个 Markdown 目录或文件开始阅读。', - chooseFolder: '选择目录', - chooseFile: '选择文件', - desktopReading: '阅读', - source: '原文', - edit: '编辑', - unsaved: '未保存', - loading: '正在加载...', - dirty: '有未保存修改', - saved: '已保存', - noOpenedDoc: '未打开文档', - chooseLeftDoc: '选择左侧 Markdown 文档开始阅读。', - chooseMarkdownDoc: '请选择一个 Markdown 文件夹或文件。', - outline: '导航', - actions: '操作', - settings: '设置', - selectMarkdownDoc: '请选择一个 Markdown 文档。', - wordCount: '字数', - reading: '阅读', - images: '图片', - codeBlocks: '代码块', - noOutline: '当前文档还没有标题层级。', - markdownStyle: '导出样式', - wordDescription: '标题、段落和列表', - pdfDescription: '干净阅读版', - copyMarkdown: '复制 Markdown', - copyPlainText: '复制纯文本', - copyHtml: '复制阅读 HTML', - htmlOutput: '阅读 HTML', - saveHtml: '保存 HTML', - favorite: '收藏', - unfavorite: '取消收藏', - pin: '置顶', - unpin: '取消置顶', - favorites: '收藏', - pinned: '置顶', - recentFiles: '最近文件', - recentWorkspaces: '最近工作区', - currentDirectory: '当前目录', - allDocuments: '全部文档', - sortUpdated: '按更新时间', - sortName: '按文件名', - sortPath: '按路径', - recursive: '递归目录', - missingImages: '缺失图片', - allImagesReady: '图片资源正常', - copyPath: '复制路径', - top: '回到顶部', - focusOutline: '专注保留大纲', - restoreLast: '启动恢复上次文档', - rememberScroll: '记住阅读位置', - defaultWorkspace: '默认工作区', - defaultReadMode: '默认阅读模式', - defaultExportStyle: '默认导出样式', - noMatches: '没有匹配的文档。', - searchResults: '全文搜索', - noSearchResults: '没有正文命中。', - insertImage: '图片', - insertImageTitle: '插入图片', - launchFailed: 'Markdown Reader 启动失败', - runtimeFailed: '前端运行时出现异常。', - choosePathFirst: '请先选择或输入 Markdown 文件夹 / 文件。', - loadFailed: '加载失败', - readFailed: '读取失败', - browserNoDir: '浏览器预览模式下不能打开本地目录。', - browserNoFile: '浏览器预览模式下不能打开本地文件。', - workspaceDialogTitle: '选择 Markdown 工作区', - markdownDialogTitle: '选择 Markdown 文件', - noUnsavedChanges: '当前没有未保存修改。', - browserDemoUpdated: '浏览器预览模式已更新示例内容。', - markdownSaved: 'Markdown 已保存,并已生成备份', - browserCopyOnlyHtml: '浏览器预览模式下仅支持复制。', - copiedMarkdown: '已复制 Markdown', - copiedPlainText: '已复制纯文本', - copiedReadingHtml: '已复制阅读 HTML', - generatedOpenedReadingHtml: '已生成并打开阅读 HTML', - browserDownloadedWord: '浏览器预览模式已下载 Word。', - generatedOpenedWord: '已生成并打开 Word', - wordExportFailed: 'Word 导出失败', - browserDownloadedPdf: '浏览器预览模式已下载 PDF。', - generatedOpenedPdf: '已生成并打开 PDF', - pdfExportFailed: 'PDF 导出失败', - openMarkdownFirst: '请先打开一个 Markdown 文件。', - browserNoLocalImage: '浏览器预览模式下不能插入本地图片。', - insertedImage: '已插入图片', - insertImageFailed: '插入图片失败', - discardPrompt: '当前文档有未保存修改,确定要切换吗?', - copiedPath: '已复制路径', - stateSaved: '设置已保存', - }, - en: { - brandSubtitle: 'Local Markdown reader', - languageToggleAria: 'Interface language', - switchLanguageTitle: 'Switch to Chinese', - workspaceAria: 'Workspace path', - workspacePlaceholder: 'Choose or enter a Markdown folder / file', - collapseDocs: 'Collapse library', - expandDocs: 'Expand library', - collapsePanel: 'Collapse right panel', - expandPanel: 'Expand right panel', - focusMode: 'Focus mode', - exitFocus: 'Exit focus', - openFolder: 'Open folder', - openMarkdownFile: 'Open Markdown file', - quickOpen: 'Quick open', - refresh: 'Refresh', - saveMarkdown: 'Save', - documents: 'Library', - searchPlaceholder: 'Search files, headings, or body', - noArticlesInPath: 'No Markdown documents found in this path.', - chooseWorkspaceOrFileFirst: 'Choose a Markdown folder or file to start.', - chooseFolder: 'Choose folder', - chooseFile: 'Choose file', - desktopReading: 'Read', - source: 'Source', - edit: 'Edit', - unsaved: 'Unsaved', - loading: 'Loading...', - dirty: 'Unsaved changes', - saved: 'Saved', - noOpenedDoc: 'No document open', - chooseLeftDoc: 'Choose a Markdown document from the left.', - chooseMarkdownDoc: 'Choose a Markdown folder or file.', - outline: 'Nav', - actions: 'Actions', - settings: 'Settings', - selectMarkdownDoc: 'Choose a Markdown document.', - wordCount: 'Words', - reading: 'Read', - images: 'Images', - codeBlocks: 'Code blocks', - noOutline: 'This document has no heading structure yet.', - markdownStyle: 'Export style', - wordDescription: 'Headings and lists', - pdfDescription: 'Clean reading version', - copyMarkdown: 'Copy Markdown', - copyPlainText: 'Copy text', - copyHtml: 'Copy reading HTML', - htmlOutput: 'Reading HTML', - saveHtml: 'Save HTML', - favorite: 'Favorite', - unfavorite: 'Unfavorite', - pin: 'Pin', - unpin: 'Unpin', - favorites: 'Favorites', - pinned: 'Pinned', - recentFiles: 'Recent files', - recentWorkspaces: 'Recent workspaces', - currentDirectory: 'Current dir', - allDocuments: 'All documents', - sortUpdated: 'Updated', - sortName: 'Name', - sortPath: 'Path', - recursive: 'Recursive', - missingImages: 'Missing images', - allImagesReady: 'Images ready', - copyPath: 'Copy path', - top: 'Back to top', - focusOutline: 'Keep outline in focus', - restoreLast: 'Restore last document', - rememberScroll: 'Remember reading position', - defaultWorkspace: 'Default workspace', - defaultReadMode: 'Default read mode', - defaultExportStyle: 'Default export style', - noMatches: 'No matching documents.', - searchResults: 'Full-text search', - noSearchResults: 'No body hits.', - insertImage: 'Image', - insertImageTitle: 'Insert image', - launchFailed: 'Markdown Reader failed to start', - runtimeFailed: 'The frontend hit a runtime error.', - choosePathFirst: 'Choose or enter a Markdown folder / file first.', - loadFailed: 'Load failed', - readFailed: 'Read failed', - browserNoDir: 'Local folders cannot be opened in browser preview mode.', - browserNoFile: 'Local files cannot be opened in browser preview mode.', - workspaceDialogTitle: 'Choose Markdown workspace', - markdownDialogTitle: 'Choose Markdown file', - noUnsavedChanges: 'There are no unsaved changes.', - browserDemoUpdated: 'Demo content updated in browser preview mode.', - markdownSaved: 'Markdown saved with backup', - browserCopyOnlyHtml: 'Browser preview mode only supports copying.', - copiedMarkdown: 'Markdown copied', - copiedPlainText: 'Plain text copied', - copiedReadingHtml: 'Reading HTML copied', - generatedOpenedReadingHtml: 'Generated and opened reading HTML', - browserDownloadedWord: 'Word downloaded in browser preview mode.', - generatedOpenedWord: 'Generated and opened Word', - wordExportFailed: 'Word export failed', - browserDownloadedPdf: 'PDF downloaded in browser preview mode.', - generatedOpenedPdf: 'Generated and opened PDF', - pdfExportFailed: 'PDF export failed', - openMarkdownFirst: 'Open a Markdown file first.', - browserNoLocalImage: 'Local images cannot be inserted in browser preview mode.', - insertedImage: 'Image inserted', - insertImageFailed: 'Insert image failed', - discardPrompt: 'This document has unsaved changes. Switch anyway?', - copiedPath: 'Path copied', - stateSaved: 'Settings saved', - }, -} as const - -type Language = keyof typeof uiText -type UiText = (typeof uiText)[Language] - function App() { const [workspace, setWorkspace] = useState('') const [articles, setArticles] = useState([]) @@ -356,6 +85,8 @@ function App() { const [livePreviewContent, setLivePreviewContent] = useState('') const [query, setQuery] = useState('') const [searchResults, setSearchResults] = useState([]) + const [quickOpenQuery, setQuickOpenQuery] = useState('') + const [quickOpenSearchResults, setQuickOpenSearchResults] = useState([]) const [readMode, setReadMode] = useState('desktop') const [panelTab, setPanelTab] = useState('outline') const [sortMode, setSortMode] = useState('updated') @@ -370,6 +101,7 @@ function App() { const [loading, setLoading] = useState(false) const [notice, setNotice] = useState('') const [imagePreview, setImagePreview] = useState('') + const readerStateRef = useRef(defaultReaderState) const text = uiText[language] const readerScrollRef = useRef(null) const editorScrollRef = useRef(null) @@ -378,8 +110,7 @@ function App() { const isDirty = Boolean(payload && editedContent !== payload.content) const previewContent = livePreviewContent || (isDirty ? editedContent : payload?.preview_content || '') const searchTerm = query.trim() - const activeMarkdownStyle = useMemo(() => getWordStylePreset(wordStyle), [wordStyle]) - const articleStyle = useMemo(() => markdownStyleVars(activeMarkdownStyle), [activeMarkdownStyle]) + const articleStyleClass = `article-style-${wordStyle}` const previewParsed = useMemo(() => parseArticle(previewContent), [previewContent]) const articleHtml = useMemo( () => highlightHtml(markdownToHtml(previewParsed.body), searchTerm), @@ -393,41 +124,24 @@ function App() { const recentFileSet = useMemo(() => new Set(readerState.recent_files), [readerState.recent_files]) const favoriteSet = useMemo(() => new Set(readerState.favorites), [readerState.favorites]) const pinnedSet = useMemo(() => new Set(readerState.pinned), [readerState.pinned]) - const visibleArticles = useMemo(() => { - const normalizedQuery = searchTerm.toLowerCase() - const selectedDir = selectedArticle?.relative_path.includes('/') - ? selectedArticle.relative_path.split('/').slice(0, -1).join('/') - : '' - const filtered = articles.filter((article) => { - if (libraryFilter === 'favorites' && !favoriteSet.has(article.path)) return false - if (libraryFilter === 'recent' && !recentFileSet.has(article.path)) return false - if (libraryFilter === 'current') { - const articleDir = article.relative_path.includes('/') - ? article.relative_path.split('/').slice(0, -1).join('/') - : '' - if (articleDir !== selectedDir) return false - } - if (!normalizedQuery) return true - return `${article.title} ${article.file_name} ${article.relative_path}`.toLowerCase().includes(normalizedQuery) - }) - - return [...filtered].sort((a, b) => { - const pinnedDelta = Number(pinnedSet.has(b.path)) - Number(pinnedSet.has(a.path)) - if (pinnedDelta) return pinnedDelta - if (sortMode === 'name') return a.file_name.localeCompare(b.file_name, 'zh-CN') - if (sortMode === 'path') return a.relative_path.localeCompare(b.relative_path, 'zh-CN') - return b.updated - a.updated - }) - }, [articles, favoriteSet, libraryFilter, pinnedSet, recentFileSet, searchTerm, selectedArticle, sortMode]) + const visibleArticles = useMemo( + () => getVisibleArticles({ + articles, + favoriteSet, + libraryFilter, + pinnedSet, + query: searchTerm, + recentFileSet, + selectedArticle, + sortMode, + }), + [articles, favoriteSet, libraryFilter, pinnedSet, recentFileSet, searchTerm, selectedArticle, sortMode], + ) - const groupedArticles = useMemo(() => { - return visibleArticles.reduce>((acc, article) => { - const group = pinnedSet.has(article.path) ? text.pinned : displayGroupName(article.group, language) - acc[group] = acc[group] || [] - acc[group].push(article) - return acc - }, {}) - }, [language, pinnedSet, text.pinned, visibleArticles]) + const groupedArticles = useMemo( + () => groupArticlesByDisplayName(visibleArticles, pinnedSet, language, text.pinned), + [language, pinnedSet, text.pinned, visibleArticles], + ) useEffect(() => { void bootstrap() @@ -479,21 +193,7 @@ function App() { } const timer = window.setTimeout(() => { if (!isTauri()) { - const lower = trimmed.toLowerCase() - setSearchResults( - demoPayload.content.toLowerCase().includes(lower) - ? [{ - path: demoArticle.path, - file_name: demoArticle.file_name, - title: demoArticle.title, - relative_path: demoArticle.relative_path, - heading: 'Demo', - snippet: makeClientSnippet(demoPayload.content, lower), - line: 1, - score: 1, - }] - : [], - ) + setSearchResults(searchDemoArticles(trimmed, language)) return } void invoke('search_workspace', { @@ -503,14 +203,34 @@ function App() { .catch((error) => setNotice(`${text.loadFailed}: ${String(error)}`)) }, 220) return () => window.clearTimeout(timer) - }, [query, text.loadFailed, workspace]) + }, [language, query, text.loadFailed, workspace]) + + useEffect(() => { + const trimmed = quickOpenQuery.trim() + if (!isQuickOpenOpen || !trimmed || !workspace.trim()) { + setQuickOpenSearchResults([]) + return undefined + } + const timer = window.setTimeout(() => { + if (!isTauri()) { + setQuickOpenSearchResults(searchDemoArticles(trimmed, language)) + return + } + void invoke('search_workspace', { + request: { workspace, query: trimmed }, + }) + .then(setQuickOpenSearchResults) + .catch((error) => setNotice(`${text.loadFailed}: ${String(error)}`)) + }, 180) + return () => window.clearTimeout(timer) + }, [isQuickOpenOpen, language, quickOpenQuery, text.loadFailed, workspace]) useEffect(() => { function handleKeyDown(event: KeyboardEvent) { const key = event.key.toLowerCase() if ((event.ctrlKey || event.metaKey) && key === 'p') { event.preventDefault() - setIsQuickOpenOpen(true) + openQuickOpen() } if ((event.ctrlKey || event.metaKey) && event.shiftKey && key === 'f') { event.preventDefault() @@ -536,7 +256,11 @@ function App() { } if ((event.ctrlKey || event.metaKey) && key === 'e') { event.preventDefault() - setReadMode((value) => (value === 'edit' ? 'desktop' : 'edit')) + setReadMode((value) => { + const nextMode = value === 'edit' ? 'desktop' : 'edit' + patchState((current) => ({ ...current, last_read_mode: nextMode })) + return nextMode + }) } if ((event.ctrlKey || event.metaKey) && key === '.') { event.preventDefault() @@ -548,11 +272,10 @@ function App() { }) async function bootstrap() { - const nextState = await loadState() - setReaderState(nextState) + const nextState = applyReaderState(await loadState()) setLanguage(nextState.settings.language) setWordStyle(nextState.settings.default_export_style) - setReadMode(nextState.settings.default_read_mode) + setReadMode(nextState.last_read_mode || nextState.settings.default_read_mode) setIsFocusMode(nextState.focus_mode) const launchPath = isTauri() ? await invoke('initial_open_path').catch(() => null) : null @@ -568,36 +291,50 @@ function App() { if (!isTauri() && new URLSearchParams(window.location.search).get('demo') === '1') { setWorkspace('demo') - setArticles([demoArticle]) - await selectArticle(demoArticle.path) + setArticles(demoArticles) + const demoTarget = demoArticles.some((article) => article.path === nextState.last_file) + ? nextState.last_file + : demoArticles[0]?.path || '' + if (demoTarget) await selectArticle(demoTarget) } } async function loadState(): Promise { if (!isTauri()) { const raw = window.localStorage.getItem('markdown-reader-state-v2') - return raw ? normalizeState(JSON.parse(raw)) : defaultReaderState + if (!raw) return defaultReaderState + try { + return normalizeState(JSON.parse(raw)) + } catch { + return defaultReaderState + } } const loaded = await invoke('load_reader_state') return normalizeState(loaded) } - function persistState(next: ReaderState) { + function applyReaderState(next: ReaderState) { const normalized = normalizeState(next) + readerStateRef.current = normalized setReaderState(normalized) + return normalized + } + + function persistState(next: ReaderState) { + const normalized = applyReaderState(next) if (!isTauri()) { window.localStorage.setItem('markdown-reader-state-v2', JSON.stringify(normalized)) - return + return normalized } void invoke('save_reader_state', { state: normalized }).catch((error) => { setNotice(`${text.loadFailed}: ${String(error)}`) }) + return normalized } function patchState(updater: (state: ReaderState) => ReaderState) { - const next = updater(readerState) - persistState(next) - return next + const next = updater(readerStateRef.current) + return persistState(next) } async function loadArticles(root = workspace, pathToSelect = selectedPath, state = readerState) { @@ -610,7 +347,7 @@ function App() { try { const items = isTauri() ? await invoke('scan_workspace', { workspace: root }) - : [demoArticle] + : demoArticles setArticles(items) const target = pathToSelect && items.some((item) => item.path === pathToSelect) ? pathToSelect @@ -641,7 +378,7 @@ function App() { try { const nextPayload = isTauri() ? await invoke('read_article', { path }) - : demoPayload + : demoPayloads[path] || demoDefaultPayload setPayload(nextPayload) setEditedContent(nextPayload.content) const nextState = patchState((current) => ({ @@ -693,7 +430,7 @@ function App() { } try { if (!isTauri()) { - const nextPayload = { ...demoPayload, content: editedContent, preview_content: editedContent } + const nextPayload = { ...payload, content: editedContent, preview_content: editedContent } setPayload(nextPayload) setNotice(text.browserDemoUpdated) return @@ -714,7 +451,7 @@ function App() { if (!workspace.trim()) return const items = isTauri() ? await invoke('scan_workspace', { workspace }) - : [demoArticle] + : demoArticles setArticles(items) if (pathToKeep && items.some((item) => item.path === pathToKeep)) { setSelectedPath(pathToKeep) @@ -839,7 +576,7 @@ function App() { } function rememberScroll() { - if (!selectedPath || !readerState.settings.remember_scroll_position) return + if (!selectedPath || !readerStateRef.current.settings.remember_scroll_position) return if (scrollSaveTimer.current) window.clearTimeout(scrollSaveTimer.current) const scrollTop = readerScrollRef.current?.scrollTop || 0 scrollSaveTimer.current = window.setTimeout(() => { @@ -854,7 +591,7 @@ function App() { } function jumpToOutline(item: OutlineItem) { - setReadMode('desktop') + changeReadMode('desktop') window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { const heading = document.getElementById(item.id) @@ -879,14 +616,19 @@ function App() { setIsFocusMode((value) => { const next = !value if (next) { - setReadMode('desktop') setPanelTab('outline') } - patchState((current) => ({ ...current, focus_mode: next })) + patchState((current) => ({ ...current, focus_mode: next, last_read_mode: next ? 'desktop' : current.last_read_mode })) + if (next) setReadMode('desktop') return next }) } + function changeReadMode(nextMode: ReadMode) { + setReadMode(nextMode) + patchState((current) => ({ ...current, last_read_mode: nextMode })) + } + function toggleFavorite(path: string) { patchState((current) => ({ ...current, @@ -902,7 +644,7 @@ function App() { } function updateSettings(settings: Partial) { - const nextSettings = { ...readerState.settings, ...settings } + const nextSettings = { ...readerStateRef.current.settings, ...settings } if (settings.language) setLanguage(settings.language) if (settings.default_export_style) setWordStyle(settings.default_export_style) patchState((current) => ({ ...current, settings: nextSettings })) @@ -915,6 +657,25 @@ function App() { } } + function openQuickOpen() { + setQuickOpenQuery('') + setQuickOpenSearchResults([]) + setIsQuickOpenOpen(true) + } + + function closeQuickOpen() { + setIsQuickOpenOpen(false) + setQuickOpenQuery('') + setQuickOpenSearchResults([]) + } + + function changeLibraryFilter(nextFilter: LibraryFilter) { + setLibraryFilter(nextFilter) + if (nextFilter !== 'all' && query.trim()) { + setQuery('') + } + } + return (
@@ -963,7 +724,7 @@ function App() { - - - @@ -1063,7 +824,7 @@ function App() {
setIsQuickOpenOpen(false)} - onQueryChange={setQuery} + onClose={closeQuickOpen} + onQueryChange={setQuickOpenQuery} onSelect={(path) => { - setIsQuickOpenOpen(false) + closeQuickOpen() void selectArticle(path) }} /> @@ -1183,135 +944,9 @@ function App() { ) } -function LibrarySidebar({ - articles, - favoriteSet, - groupedArticles, - language, - libraryFilter, - loading, - pinnedSet, - query, - searchResults, - selectedPath, - sortMode, - text, - visibleCount, - workspace, - onChooseFile, - onChooseWorkspace, - onFilterChange, - onQueryChange, - onSelectArticle, - onSortChange, - onToggleFavorite, - onTogglePinned, -}: { - articles: ArticleSummary[] - favoriteSet: Set - groupedArticles: Record - language: Language - libraryFilter: LibraryFilter - loading: boolean - pinnedSet: Set - query: string - searchResults: SearchResult[] - selectedPath: string - sortMode: SortMode - text: UiText - visibleCount: number - workspace: string - onChooseFile: () => void - onChooseWorkspace: () => void - onFilterChange: (filter: LibraryFilter) => void - onQueryChange: (value: string) => void - onSelectArticle: (path: string) => void - onSortChange: (sort: SortMode) => void - onToggleFavorite: (path: string) => void - onTogglePinned: (path: string) => void -}) { - return ( - - ) -} - function ReaderContent({ articleHtml, - articleStyle, + articleStyleClass, editorScrollRef, isDirty, loading, @@ -1329,7 +964,7 @@ function ReaderContent({ workspace, }: { articleHtml: string - articleStyle: CSSProperties + articleStyleClass: string editorScrollRef: RefObject isDirty: boolean loading: boolean @@ -1387,7 +1022,7 @@ function ReaderContent({ ) } return ( -
+

{previewParsed.title || selectedArticle?.title}

{previewParsed.digest &&

{previewParsed.digest}

} @@ -1399,7 +1034,7 @@ function ReaderContent({ function FocusReader({ articleHtml, - articleStyle, + articleStyleClass, keepOutline, language, loading, @@ -1415,7 +1050,7 @@ function FocusReader({ text, }: { articleHtml: string - articleStyle: CSSProperties + articleStyleClass: string keepOutline: boolean language: Language loading: boolean @@ -1450,7 +1085,7 @@ function FocusReader({ {loading &&
{text.loading}
} {!loading && !payload &&

{text.selectMarkdownDoc}

} {!loading && payload && ( -
+

{previewParsed.title}

{previewParsed.digest &&

{previewParsed.digest}

} @@ -1534,7 +1169,7 @@ function OutlineOnly({ outline, text, onSelect }: { outline: OutlineItem[]; text return outline.length > 0 ? (
{outline.map((item) => ( - @@ -1663,84 +1298,6 @@ function ToggleRow({ label, checked, onChange }: { label: string; checked: boole ) } -function QuickOpenDialog({ - articles, - favoriteSet, - language, - pinnedSet, - recentFiles, - searchResults, - text, - onClose, - onQueryChange, - onSelect, -}: { - articles: ArticleSummary[] - favoriteSet: Set - language: Language - pinnedSet: Set - recentFiles: string[] - searchResults: SearchResult[] - text: UiText - onClose: () => void - onQueryChange: (value: string) => void - onSelect: (path: string) => void -}) { - const [filter, setFilter] = useState('') - const inputRef = useRef(null) - const articleByPath = useMemo(() => new Map(articles.map((article) => [article.path, article])), [articles]) - const filtered = useMemo(() => { - const target = filter.trim().toLowerCase() - if (target) { - const fromSearch = searchResults.map((result) => articleByPath.get(result.path)).filter(Boolean) as ArticleSummary[] - const localMatches = articles.filter((article) => `${article.title} ${article.file_name} ${article.relative_path}`.toLowerCase().includes(target)) - return uniqueArticles([...fromSearch, ...localMatches]).slice(0, 16) - } - const favorites = articles.filter((article) => favoriteSet.has(article.path)) - const pinned = articles.filter((article) => pinnedSet.has(article.path)) - const recents = recentFiles.map((path) => articleByPath.get(path)).filter(Boolean) as ArticleSummary[] - return uniqueArticles([...pinned, ...favorites, ...recents, ...articles]).slice(0, 16) - }, [articleByPath, articles, favoriteSet, filter, pinnedSet, recentFiles, searchResults]) - - useEffect(() => { - inputRef.current?.focus() - }, []) - - useEffect(() => { - onQueryChange(filter) - }, [filter, onQueryChange]) - - return ( -
-
event.stopPropagation()}> -
- - setFilter(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape') onClose() - if (event.key === 'Enter' && filtered[0]) onSelect(filtered[0].path) - }} - placeholder={text.searchPlaceholder} - /> -
-
- {filtered.map((article) => ( - - ))} - {filtered.length === 0 &&
{text.noMatches}
} -
-
-
- ) -} - function RichMarkdownEditor({ scrollRef, text, @@ -1777,16 +1334,29 @@ function FallbackMarkdownEditor({ onSave: () => void }) { const textareaRef = useRef(null) + const selectionRef = useRef<{ start: number, end: number } | null>(null) const [isInsertingImage, setIsInsertingImage] = useState(false) + function rememberSelection(target = textareaRef.current) { + if (!target) return + selectionRef.current = { + start: target.selectionStart, + end: target.selectionEnd, + } + } + async function insertImage() { setIsInsertingImage(true) try { const markdown = await onRequestImageMarkdown() if (!markdown) return const textarea = textareaRef.current - const start = textarea?.selectionStart ?? value.length - const end = textarea?.selectionEnd ?? start + const activeSelection = textarea && document.activeElement === textarea && selectionRef.current + ? { start: textarea.selectionStart, end: textarea.selectionEnd } + : selectionRef.current + const fallbackIndex = defaultImageInsertionIndex(value) + const start = clampIndex(activeSelection?.start ?? fallbackIndex, value.length) + const end = clampIndex(activeSelection?.end ?? start, value.length) const before = value.slice(0, start) const after = value.slice(end) const prefix = before && !before.endsWith('\n') ? '\n\n' : '' @@ -1795,6 +1365,7 @@ function FallbackMarkdownEditor({ const nextValue = `${before}${insertion}${after}` const nextCursor = before.length + insertion.length onChange(nextValue) + selectionRef.current = { start: nextCursor, end: nextCursor } window.requestAnimationFrame(() => { textarea?.focus() textarea?.setSelectionRange(nextCursor, nextCursor) @@ -1816,7 +1387,13 @@ function FallbackMarkdownEditor({ ref={textareaRef} className="fallback-markdown-editor" value={value} - onChange={(event) => onChange(event.target.value)} + onChange={(event) => { + rememberSelection(event.target) + onChange(event.target.value) + }} + onClick={() => rememberSelection()} + onKeyUp={() => rememberSelection()} + onMouseUp={() => rememberSelection()} wrap="off" onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { @@ -1830,6 +1407,25 @@ function FallbackMarkdownEditor({ ) } +function clampIndex(index: number, max: number) { + return Math.min(Math.max(index, 0), max) +} + +function defaultImageInsertionIndex(markdown: string) { + if (!/^---\r?\n/.test(markdown)) return markdown.length + + const lines = markdown.split(/(\r?\n)/) + let cursor = lines[0].length + (lines[1]?.length || 0) + for (let index = 2; index < lines.length; index += 2) { + const line = lines[index] + const lineBreak = lines[index + 1] || '' + if (line === '---') return cursor + line.length + lineBreak.length + cursor += line.length + lineBreak.length + } + + return markdown.length +} + function AppErrorFallback({ message }: { message: string }) { return (
@@ -1861,161 +1457,6 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, { messa } } -function normalizeState(value: ReaderState): ReaderState { - return { - ...defaultReaderState, - ...value, - settings: { ...defaultSettings, ...(value?.settings || {}) }, - recent_workspaces: trimList(value?.recent_workspaces || [], 20), - recent_files: trimList(value?.recent_files || [], 50), - favorites: trimList(value?.favorites || [], 500), - pinned: trimList(value?.pinned || [], 500), - reading_positions: value?.reading_positions || {}, - } -} - -function trimList(values: string[], max: number) { - return [...new Set(values.filter(Boolean))].slice(0, max) -} - -function moveToFront(values: string[], path: string, max: number) { - return [path, ...values.filter((value) => value !== path)].slice(0, max) -} - -function togglePath(values: string[], path: string) { - return values.includes(path) ? values.filter((value) => value !== path) : [path, ...values] -} - -function uniqueArticles(items: ArticleSummary[]) { - const seen = new Set() - return items.filter((item) => { - if (seen.has(item.path)) return false - seen.add(item.path) - return true - }) -} - -function highlightHtml(html: string, term: string) { - if (!term) return html - const escaped = escapeRegExp(term) - if (!escaped) return html - const pattern = new RegExp(`(${escaped})`, 'gi') - return html - .split(/(<[^>]+>)/g) - .map((part) => (part.startsWith('<') ? part : part.replace(pattern, '$1'))) - .join('') -} - -function extractImageSources(markdown: string) { - return [...markdown.matchAll(/!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)] - .map((match) => match[1]) - .filter((src) => !src.startsWith('data:image/')) -} - -function markdownToPlainText(markdown: string) { - return parseArticle(markdown).body - .replace(/```[\s\S]*?```/g, '') - .replace(/!\[([^\]]*)]\([^)]+\)/g, '$1') - .replace(/\[([^\]]+)]\([^)]+\)/g, '$1') - .replace(/^#{1,6}\s+/gm, '') - .replace(/[*_`>~]/g, '') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - -function makeClientSnippet(content: string, query: string) { - const lower = content.toLowerCase() - const index = lower.indexOf(query) - if (index < 0) return content.slice(0, 120) - return content.slice(Math.max(0, index - 42), index + 84).replace(/\s+/g, ' ') -} - -function base64ToArrayBuffer(base64: string) { - const binary = window.atob(base64) - const bytes = new Uint8Array(binary.length) - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) - return bytes.buffer -} - -async function readBundledPdfFont() { - const response = await fetch('fonts/NotoSansSC-VF.ttf') - if (!response.ok) throw new Error('内置 PDF 字体加载失败') - return arrayBufferToBase64(await response.arrayBuffer()) -} - -function downloadBase64File(base64: string, filename: string, mimeType: string) { - const bytes = base64ToArrayBuffer(base64) - const blob = new Blob([bytes], { type: mimeType }) - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = filename - link.click() - URL.revokeObjectURL(link.href) -} - -function exportFileName(name: string | undefined, extension: string) { - return `${(name || 'document').replace(/\.[^.]+$/, '')}.${extension}` -} - -function formatArticleCount(count: number, language: Language) { - return language === 'zh' ? `${count} 篇` : `${count} docs` -} - -function formatWordCount(count: number, language: Language) { - return language === 'zh' ? `${count} 字` : `${count} words` -} - -function formatReadingMinutes(minutes: number, language: Language) { - return language === 'zh' ? `${minutes} 分钟` : `${minutes} min` -} - -function formatImageCount(count: number, language: Language, short = false) { - if (language === 'zh') return short ? `${count} 图` : `${count} 张图片` - return short ? `${count} img` : `${count} images` -} - -function formatExportSummary(stats: ArticleStats, language: Language) { - const minutes = stats.readingMinutes || 1 - const images = stats.images || 0 - return language === 'zh' ? `${minutes} 分钟阅读,${images} 张图片` : `${minutes} min read, ${images} images` -} - -function displayGroupName(group: string, language: Language) { - if (language === 'zh') return group - const groups: Record = { - 示例: 'Demo', - 文档: 'Documents', - 草稿: 'Drafts', - 审稿: 'Review', - 已确认: 'Approved', - 已确认稿: 'Approved', - } - return groups[group] || group -} - -function markdownStyleVars(preset: ReturnType): CSSProperties { - return { - '--md-font': `${preset.font}, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`, - '--md-accent': preset.accent, - '--md-body-size': `${Math.max(14, Math.round(preset.bodySize * 0.72))}px`, - '--md-line-height': String(Math.max(1.55, Math.min(2.12, preset.lineSpacing / 170))), - } as CSSProperties -} - -function arrayBufferToBase64(buffer: ArrayBuffer) { - const bytes = new Uint8Array(buffer) - const chunks: string[] = [] - const chunkSize = 0x8000 - for (let index = 0; index < bytes.length; index += chunkSize) { - chunks.push(String.fromCharCode(...bytes.subarray(index, index + chunkSize))) - } - return window.btoa(chunks.join('')) -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - function isTauri() { return Boolean('__TAURI_INTERNALS__' in window) } diff --git a/src/LibrarySidebar.tsx b/src/LibrarySidebar.tsx new file mode 100644 index 0000000..83fe9b3 --- /dev/null +++ b/src/LibrarySidebar.tsx @@ -0,0 +1,147 @@ +import { + FileSearch, + FileText, + FolderOpen, + Pin, + Search, + SlidersHorizontal, + Star, +} from 'lucide-react' +import { + formatArticleCount, + type Language, + type UiText, +} from './i18n' +import type { + ArticleSummary, + LibraryFilter, + SearchResult, + SortMode, +} from './types' + +export function LibrarySidebar({ + articles, + favoriteSet, + groupedArticles, + language, + libraryFilter, + loading, + pinnedSet, + query, + searchResults, + selectedPath, + sortMode, + text, + visibleCount, + workspace, + onChooseFile, + onChooseWorkspace, + onFilterChange, + onQueryChange, + onSelectArticle, + onSortChange, + onToggleFavorite, + onTogglePinned, +}: { + articles: ArticleSummary[] + favoriteSet: Set + groupedArticles: Record + language: Language + libraryFilter: LibraryFilter + loading: boolean + pinnedSet: Set + query: string + searchResults: SearchResult[] + selectedPath: string + sortMode: SortMode + text: UiText + visibleCount: number + workspace: string + onChooseFile: () => void + onChooseWorkspace: () => void + onFilterChange: (filter: LibraryFilter) => void + onQueryChange: (value: string) => void + onSelectArticle: (path: string) => void + onSortChange: (sort: SortMode) => void + onToggleFavorite: (path: string) => void + onTogglePinned: (path: string) => void +}) { + return ( + + ) +} diff --git a/src/QuickOpenDialog.tsx b/src/QuickOpenDialog.tsx new file mode 100644 index 0000000..5f454f0 --- /dev/null +++ b/src/QuickOpenDialog.tsx @@ -0,0 +1,83 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { FileText, Search } from 'lucide-react' +import { displayGroupName, type Language, type UiText } from './i18n' +import { uniqueArticles } from './librarySearch' +import type { ArticleSummary, SearchResult } from './types' + +export function QuickOpenDialog({ + articles, + favoriteSet, + language, + pinnedSet, + recentFiles, + searchResults, + text, + onClose, + onQueryChange, + onSelect, +}: { + articles: ArticleSummary[] + favoriteSet: Set + language: Language + pinnedSet: Set + recentFiles: string[] + searchResults: SearchResult[] + text: UiText + onClose: () => void + onQueryChange: (value: string) => void + onSelect: (path: string) => void +}) { + const [filter, setFilter] = useState('') + const inputRef = useRef(null) + const articleByPath = useMemo(() => new Map(articles.map((article) => [article.path, article])), [articles]) + const filtered = useMemo(() => { + const target = filter.trim().toLowerCase() + if (target) { + const fromSearch = searchResults.map((result) => articleByPath.get(result.path)).filter(Boolean) as ArticleSummary[] + const localMatches = articles.filter((article) => `${article.title} ${article.file_name} ${article.relative_path}`.toLowerCase().includes(target)) + return uniqueArticles([...fromSearch, ...localMatches]).slice(0, 16) + } + const favorites = articles.filter((article) => favoriteSet.has(article.path)) + const pinned = articles.filter((article) => pinnedSet.has(article.path)) + const recents = recentFiles.map((path) => articleByPath.get(path)).filter(Boolean) as ArticleSummary[] + return uniqueArticles([...pinned, ...favorites, ...recents, ...articles]).slice(0, 16) + }, [articleByPath, articles, favoriteSet, filter, pinnedSet, recentFiles, searchResults]) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + useEffect(() => { + onQueryChange(filter) + }, [filter, onQueryChange]) + + return ( +
+
event.stopPropagation()}> +
+ + setFilter(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') onClose() + if (event.key === 'Enter' && filtered[0]) onSelect(filtered[0].path) + }} + placeholder={text.searchPlaceholder} + /> +
+
+ {filtered.map((article) => ( + + ))} + {filtered.length === 0 &&
{text.noMatches}
} +
+
+
+ ) +} diff --git a/src/exportHelpers.ts b/src/exportHelpers.ts new file mode 100644 index 0000000..2a5700e --- /dev/null +++ b/src/exportHelpers.ts @@ -0,0 +1,49 @@ +import { parseArticle } from './markdown' + +export function markdownToPlainText(markdown: string) { + return parseArticle(markdown).body + .replace(/```[\s\S]*?```/g, '') + .replace(/!\[([^\]]*)]\([^)]+\)/g, '$1') + .replace(/\[([^\]]+)]\([^)]+\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/[*_`>~]/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +export async function readBundledPdfFont() { + const response = await fetch('fonts/NotoSansSC-VF.ttf') + if (!response.ok) throw new Error('内置 PDF 字体加载失败') + return arrayBufferToBase64(await response.arrayBuffer()) +} + +export function downloadBase64File(base64: string, filename: string, mimeType: string) { + const bytes = base64ToArrayBuffer(base64) + const blob = new Blob([bytes], { type: mimeType }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + link.click() + URL.revokeObjectURL(link.href) +} + +export function exportFileName(name: string | undefined, extension: string) { + return `${(name || 'document').replace(/\.[^.]+$/, '')}.${extension}` +} + +function base64ToArrayBuffer(base64: string) { + const binary = window.atob(base64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes.buffer +} + +function arrayBufferToBase64(buffer: ArrayBuffer) { + const bytes = new Uint8Array(buffer) + const chunks: string[] = [] + const chunkSize = 0x8000 + for (let index = 0; index < bytes.length; index += chunkSize) { + chunks.push(String.fromCharCode(...bytes.subarray(index, index + chunkSize))) + } + return window.btoa(chunks.join('')) +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..8163cec --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,263 @@ +import type { ArticleStats } from './types' + +export const uiText = { + zh: { + brandSubtitle: '本地 Markdown 阅读器', + languageToggleAria: '界面语言', + switchLanguageTitle: '切换到 English', + workspaceAria: '工作区路径', + workspacePlaceholder: '选择或输入 Markdown 文件夹 / 文件', + collapseDocs: '收起文档库', + expandDocs: '展开文档库', + collapsePanel: '收起右栏', + expandPanel: '展开右栏', + focusMode: '专注模式', + exitFocus: '退出专注', + openFolder: '打开目录', + openMarkdownFile: '打开 Markdown 文件', + quickOpen: '快速打开', + refresh: '刷新', + saveMarkdown: '保存', + documents: '文档库', + searchPlaceholder: '搜索文件名、标题或正文', + noArticlesInPath: '当前路径没有找到 Markdown 文档。', + chooseWorkspaceOrFileFirst: '选择一个 Markdown 目录或文件开始阅读。', + chooseFolder: '选择目录', + chooseFile: '选择文件', + desktopReading: '阅读', + source: '原文', + edit: '编辑', + unsaved: '未保存', + loading: '正在加载...', + dirty: '有未保存修改', + saved: '已保存', + noOpenedDoc: '未打开文档', + chooseLeftDoc: '选择左侧 Markdown 文档开始阅读。', + chooseMarkdownDoc: '请选择一个 Markdown 文件夹或文件。', + outline: '导航', + actions: '操作', + settings: '设置', + selectMarkdownDoc: '请选择一个 Markdown 文档。', + wordCount: '字数', + reading: '阅读', + images: '图片', + codeBlocks: '代码块', + noOutline: '当前文档还没有标题层级。', + markdownStyle: '导出样式', + wordDescription: '标题、段落和列表', + pdfDescription: '干净阅读版', + copyMarkdown: '复制 Markdown', + copyPlainText: '复制纯文本', + copyHtml: '复制阅读 HTML', + htmlOutput: '阅读 HTML', + saveHtml: '保存 HTML', + favorite: '收藏', + unfavorite: '取消收藏', + pin: '置顶', + unpin: '取消置顶', + favorites: '收藏', + pinned: '置顶', + recentFiles: '最近文件', + recentWorkspaces: '最近工作区', + currentDirectory: '当前目录', + allDocuments: '全部文档', + sortUpdated: '按更新时间', + sortName: '按文件名', + sortPath: '按路径', + recursive: '递归目录', + missingImages: '缺失图片', + allImagesReady: '图片资源正常', + copyPath: '复制路径', + top: '回到顶部', + focusOutline: '专注保留大纲', + restoreLast: '启动恢复上次文档', + rememberScroll: '记住阅读位置', + defaultWorkspace: '默认工作区', + defaultReadMode: '默认阅读模式', + defaultExportStyle: '默认导出样式', + noMatches: '没有匹配的文档。', + searchResults: '全文搜索', + noSearchResults: '没有正文命中。', + insertImage: '图片', + insertImageTitle: '插入图片', + launchFailed: 'Markdown Reader 启动失败', + runtimeFailed: '前端运行时出现异常。', + choosePathFirst: '请先选择或输入 Markdown 文件夹 / 文件。', + loadFailed: '加载失败', + readFailed: '读取失败', + browserNoDir: '浏览器预览模式下不能打开本地目录。', + browserNoFile: '浏览器预览模式下不能打开本地文件。', + workspaceDialogTitle: '选择 Markdown 工作区', + markdownDialogTitle: '选择 Markdown 文件', + noUnsavedChanges: '当前没有未保存修改。', + browserDemoUpdated: '浏览器预览模式已更新示例内容。', + markdownSaved: 'Markdown 已保存,并已生成备份', + browserCopyOnlyHtml: '浏览器预览模式下仅支持复制。', + copiedMarkdown: '已复制 Markdown', + copiedPlainText: '已复制纯文本', + copiedReadingHtml: '已复制阅读 HTML', + generatedOpenedReadingHtml: '已生成并打开阅读 HTML', + browserDownloadedWord: '浏览器预览模式已下载 Word。', + generatedOpenedWord: '已生成并打开 Word', + wordExportFailed: 'Word 导出失败', + browserDownloadedPdf: '浏览器预览模式已下载 PDF。', + generatedOpenedPdf: '已生成并打开 PDF', + pdfExportFailed: 'PDF 导出失败', + openMarkdownFirst: '请先打开一个 Markdown 文件。', + browserNoLocalImage: '浏览器预览模式下不能插入本地图片。', + insertedImage: '已插入图片', + insertImageFailed: '插入图片失败', + discardPrompt: '当前文档有未保存修改,确定要切换吗?', + copiedPath: '已复制路径', + stateSaved: '设置已保存', + }, + en: { + brandSubtitle: 'Local Markdown reader', + languageToggleAria: 'Interface language', + switchLanguageTitle: 'Switch to Chinese', + workspaceAria: 'Workspace path', + workspacePlaceholder: 'Choose or enter a Markdown folder / file', + collapseDocs: 'Collapse library', + expandDocs: 'Expand library', + collapsePanel: 'Collapse right panel', + expandPanel: 'Expand right panel', + focusMode: 'Focus mode', + exitFocus: 'Exit focus', + openFolder: 'Open folder', + openMarkdownFile: 'Open Markdown file', + quickOpen: 'Quick open', + refresh: 'Refresh', + saveMarkdown: 'Save', + documents: 'Library', + searchPlaceholder: 'Search files, headings, or body', + noArticlesInPath: 'No Markdown documents found in this path.', + chooseWorkspaceOrFileFirst: 'Choose a Markdown folder or file to start.', + chooseFolder: 'Choose folder', + chooseFile: 'Choose file', + desktopReading: 'Read', + source: 'Source', + edit: 'Edit', + unsaved: 'Unsaved', + loading: 'Loading...', + dirty: 'Unsaved changes', + saved: 'Saved', + noOpenedDoc: 'No document open', + chooseLeftDoc: 'Choose a Markdown document from the left.', + chooseMarkdownDoc: 'Choose a Markdown folder or file.', + outline: 'Nav', + actions: 'Actions', + settings: 'Settings', + selectMarkdownDoc: 'Choose a Markdown document.', + wordCount: 'Words', + reading: 'Read', + images: 'Images', + codeBlocks: 'Code blocks', + noOutline: 'This document has no heading structure yet.', + markdownStyle: 'Export style', + wordDescription: 'Headings and lists', + pdfDescription: 'Clean reading version', + copyMarkdown: 'Copy Markdown', + copyPlainText: 'Copy text', + copyHtml: 'Copy reading HTML', + htmlOutput: 'Reading HTML', + saveHtml: 'Save HTML', + favorite: 'Favorite', + unfavorite: 'Unfavorite', + pin: 'Pin', + unpin: 'Unpin', + favorites: 'Favorites', + pinned: 'Pinned', + recentFiles: 'Recent files', + recentWorkspaces: 'Recent workspaces', + currentDirectory: 'Current dir', + allDocuments: 'All documents', + sortUpdated: 'Updated', + sortName: 'Name', + sortPath: 'Path', + recursive: 'Recursive', + missingImages: 'Missing images', + allImagesReady: 'Images ready', + copyPath: 'Copy path', + top: 'Back to top', + focusOutline: 'Keep outline in focus', + restoreLast: 'Restore last document', + rememberScroll: 'Remember reading position', + defaultWorkspace: 'Default workspace', + defaultReadMode: 'Default read mode', + defaultExportStyle: 'Default export style', + noMatches: 'No matching documents.', + searchResults: 'Full-text search', + noSearchResults: 'No body hits.', + insertImage: 'Image', + insertImageTitle: 'Insert image', + launchFailed: 'Markdown Reader failed to start', + runtimeFailed: 'The frontend hit a runtime error.', + choosePathFirst: 'Choose or enter a Markdown folder / file first.', + loadFailed: 'Load failed', + readFailed: 'Read failed', + browserNoDir: 'Local folders cannot be opened in browser preview mode.', + browserNoFile: 'Local files cannot be opened in browser preview mode.', + workspaceDialogTitle: 'Choose Markdown workspace', + markdownDialogTitle: 'Choose Markdown file', + noUnsavedChanges: 'There are no unsaved changes.', + browserDemoUpdated: 'Demo content updated in browser preview mode.', + markdownSaved: 'Markdown saved with backup', + browserCopyOnlyHtml: 'Browser preview mode only supports copying.', + copiedMarkdown: 'Markdown copied', + copiedPlainText: 'Plain text copied', + copiedReadingHtml: 'Reading HTML copied', + generatedOpenedReadingHtml: 'Generated and opened reading HTML', + browserDownloadedWord: 'Word downloaded in browser preview mode.', + generatedOpenedWord: 'Generated and opened Word', + wordExportFailed: 'Word export failed', + browserDownloadedPdf: 'PDF downloaded in browser preview mode.', + generatedOpenedPdf: 'Generated and opened PDF', + pdfExportFailed: 'PDF export failed', + openMarkdownFirst: 'Open a Markdown file first.', + browserNoLocalImage: 'Local images cannot be inserted in browser preview mode.', + insertedImage: 'Image inserted', + insertImageFailed: 'Insert image failed', + discardPrompt: 'This document has unsaved changes. Switch anyway?', + copiedPath: 'Path copied', + stateSaved: 'Settings saved', + }, +} as const + +export type Language = keyof typeof uiText +export type UiText = (typeof uiText)[Language] + +export function formatArticleCount(count: number, language: Language) { + return language === 'zh' ? `${count} 篇` : `${count} docs` +} + +export function formatWordCount(count: number, language: Language) { + return language === 'zh' ? `${count} 字` : `${count} words` +} + +export function formatReadingMinutes(minutes: number, language: Language) { + return language === 'zh' ? `${minutes} 分钟` : `${minutes} min` +} + +export function formatImageCount(count: number, language: Language, short = false) { + if (language === 'zh') return short ? `${count} 图` : `${count} 张图片` + return short ? `${count} img` : `${count} images` +} + +export function formatExportSummary(stats: ArticleStats, language: Language) { + const minutes = stats.readingMinutes || 1 + const images = stats.images || 0 + return language === 'zh' ? `${minutes} 分钟阅读,${images} 张图片` : `${minutes} min read, ${images} images` +} + +export function displayGroupName(group: string, language: Language) { + if (language === 'zh') return group + const groups: Record = { + 示例: 'Demo', + 文档: 'Documents', + 草稿: 'Drafts', + 审稿: 'Review', + 已确认: 'Approved', + 已确认稿: 'Approved', + } + return groups[group] || group +} diff --git a/src/librarySearch.ts b/src/librarySearch.ts new file mode 100644 index 0000000..8bc5472 --- /dev/null +++ b/src/librarySearch.ts @@ -0,0 +1,260 @@ +import { displayGroupName, type Language } from './i18n' +import type { + ArticlePayload, + ArticleSummary, + LibraryFilter, + SearchResult, + SortMode, +} from './types' + +const demoUpdated = Math.floor(Date.now() / 1000) + +const demoArticle: ArticleSummary = { + path: 'demo.md', + file_name: 'demo.md', + title: 'Markdown Reader V2 示例', + digest: '本地阅读、全文搜索、收藏和导出放进同一个资料浏览工作台。', + group: '示例', + status: 'document', + updated: demoUpdated, + relative_path: 'demo.md', +} + +const demoSearchArticle: ArticleSummary = { + path: 'guides/search.md', + file_name: 'search.md', + title: '全文搜索和快速打开', + digest: '演示文件名、标题、正文命中以及最近文件跳转。', + group: '文档', + status: 'document', + updated: demoUpdated - 7200, + relative_path: 'guides/search.md', +} + +const demoExportArticle: ArticleSummary = { + path: 'notes/export.md', + file_name: 'export.md', + title: '复制与导出检查', + digest: '演示复制 Markdown、纯文本、阅读 HTML 和导出动作。', + group: '文档', + status: 'document', + updated: demoUpdated - 14400, + relative_path: 'notes/export.md', +} + +const demoPayload: ArticlePayload = { + path: demoArticle.path, + base_dir: '', + missing_images: [], + content: `--- +title: Markdown Reader V2 示例 +digest: 本地阅读、全文搜索、收藏和导出放进同一个资料浏览工作台。 +--- + +## 阅读器定位 + +Markdown Reader V2 面向本地文档、项目 README、PRD、排障记录和技术方案。它优先解决快速回到上次工作区、搜索正文内容、沿着大纲阅读长文和轻量修改的问题。 + +## 全文搜索 + +搜索 SQL、Tauri、产品方案这类关键词时,结果不只看文件名,也会读取 Markdown 正文、frontmatter 和标题,并展示命中片段。 + +## 图片和代码 + +![系统预览](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwSxQgAAAABJRU5ErkJggg==) + +\`\`\`ts +const result = await searchWorkspace('SQL') +await openDocument(result.path) +\`\`\` + +## 轻编辑 + +编辑模式保留 Markdown 源码、保存快捷键、保存前备份和本地图片插入。阅读器的主心智仍然是看文档,不把编辑器做重。 +`, + preview_content: '', +} +demoPayload.preview_content = demoPayload.content + +const demoSearchPayload: ArticlePayload = { + path: demoSearchArticle.path, + base_dir: '', + missing_images: [], + content: `--- +title: 全文搜索和快速打开 +digest: 演示文件名、标题、正文命中以及最近文件跳转。 +--- + +## 搜索覆盖范围 + +搜索会同时覆盖文件名、标题、摘要和 Markdown 正文。比如输入 Tauri、SQL、PRD 或快速打开,都应该能看到命中片段。 + +## 快速打开 + +快速打开优先展示置顶、收藏、最近文件,然后再展示普通文档。这个顺序可以帮助用户回到正在读的资料。 + +## 最近文件 + +打开过的文档会进入最近文件。刷新页面或重启应用后,仍然应该保留最近列表和上一次打开的文档。 +`, + preview_content: '', +} +demoSearchPayload.preview_content = demoSearchPayload.content + +const demoExportPayload: ArticlePayload = { + path: demoExportArticle.path, + base_dir: '', + missing_images: [{ alt: 'missing', src: 'assets/missing.png', resolved_path: 'notes/assets/missing.png' }], + content: `--- +title: 复制与导出检查 +digest: 演示复制 Markdown、纯文本、阅读 HTML 和导出动作。 +--- + +## 复制动作 + +右侧操作面板提供 Markdown、纯文本和阅读 HTML 复制。浏览器预览模式下,保存 HTML 会提示只支持复制。 + +## 导出动作 + +Word 和 PDF 在浏览器预览模式下会下载文件;在 Tauri 桌面应用里会保存到本地导出目录并打开。 + +## 图片状态 + +![missing](assets/missing.png) + +这篇文档故意保留一个缺失图片路径,用来验证右侧资源检查。 +`, + preview_content: '', +} +demoExportPayload.preview_content = demoExportPayload.content + +export const demoArticles = [demoArticle, demoSearchArticle, demoExportArticle] +export const demoDefaultPayload = demoPayload + +export const demoPayloads: Record = { + [demoPayload.path]: demoPayload, + [demoSearchPayload.path]: demoSearchPayload, + [demoExportPayload.path]: demoExportPayload, +} + +export function getVisibleArticles({ + articles, + favoriteSet, + libraryFilter, + pinnedSet, + query, + recentFileSet, + selectedArticle, + sortMode, +}: { + articles: ArticleSummary[] + favoriteSet: Set + libraryFilter: LibraryFilter + pinnedSet: Set + query: string + recentFileSet: Set + selectedArticle?: ArticleSummary + sortMode: SortMode +}) { + const normalizedQuery = query.trim().toLowerCase() + const selectedDir = selectedArticle?.relative_path.includes('/') + ? selectedArticle.relative_path.split('/').slice(0, -1).join('/') + : '' + const filtered = articles.filter((article) => { + if (libraryFilter === 'favorites' && !favoriteSet.has(article.path)) return false + if (libraryFilter === 'pinned' && !pinnedSet.has(article.path)) return false + if (libraryFilter === 'recent' && !recentFileSet.has(article.path)) return false + if (libraryFilter === 'current') { + const articleDir = article.relative_path.includes('/') + ? article.relative_path.split('/').slice(0, -1).join('/') + : '' + if (articleDir !== selectedDir) return false + } + if (!normalizedQuery) return true + return `${article.title} ${article.file_name} ${article.relative_path}`.toLowerCase().includes(normalizedQuery) + }) + + return [...filtered].sort((a, b) => { + const pinnedDelta = Number(pinnedSet.has(b.path)) - Number(pinnedSet.has(a.path)) + if (pinnedDelta) return pinnedDelta + if (sortMode === 'name') return a.file_name.localeCompare(b.file_name, 'zh-CN') + if (sortMode === 'path') return a.relative_path.localeCompare(b.relative_path, 'zh-CN') + return b.updated - a.updated + }) +} + +export function groupArticlesByDisplayName( + articles: ArticleSummary[], + pinnedSet: Set, + language: Language, + pinnedLabel: string, +) { + return articles.reduce>((acc, article) => { + const group = pinnedSet.has(article.path) ? pinnedLabel : displayGroupName(article.group, language) + acc[group] = acc[group] || [] + acc[group].push(article) + return acc + }, {}) +} + +export function searchDemoArticles(query: string, language: Language): SearchResult[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return [] + return demoArticles + .map((article) => { + const payload = demoPayloads[article.path] + const haystack = `${article.title} ${article.file_name} ${article.relative_path} ${article.digest} ${payload?.content || ''}`.toLowerCase() + if (!haystack.includes(normalizedQuery)) return null + const titleHit = article.title.toLowerCase().includes(normalizedQuery) + const fileHit = article.file_name.toLowerCase().includes(normalizedQuery) + return { + path: article.path, + file_name: article.file_name, + title: article.title, + relative_path: article.relative_path, + heading: displayGroupName(article.group, language), + snippet: makeClientSnippet(payload?.content || article.digest || article.title, normalizedQuery), + line: 1, + score: 1 + (titleHit ? 4 : 0) + (fileHit ? 3 : 0), + } + }) + .filter((result): result is SearchResult => Boolean(result)) + .sort((a, b) => b.score - a.score || a.relative_path.localeCompare(b.relative_path, 'zh-CN')) +} + +export function uniqueArticles(items: ArticleSummary[]) { + const seen = new Set() + return items.filter((item) => { + if (seen.has(item.path)) return false + seen.add(item.path) + return true + }) +} + +export function highlightHtml(html: string, term: string) { + if (!term) return html + const escaped = escapeRegExp(term) + if (!escaped) return html + const pattern = new RegExp(`(${escaped})`, 'gi') + return html + .split(/(<[^>]+>)/g) + .map((part) => (part.startsWith('<') ? part : part.replace(pattern, '$1'))) + .join('') +} + +export function extractImageSources(markdown: string) { + return [...markdown.matchAll(/!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)] + .map((match) => match[1]) + .filter((src) => !src.startsWith('data:image/')) +} + +function makeClientSnippet(content: string, query: string) { + const lower = content.toLowerCase() + const index = lower.indexOf(query) + if (index < 0) return content.slice(0, 120) + return content.slice(Math.max(0, index - 42), index + 84).replace(/\s+/g, ' ') +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/markdown.ts b/src/markdown.ts index e424ea5..808d7d0 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,3 +1,4 @@ +import DOMPurify from 'dompurify' import { marked } from 'marked' import type { ArticleStats, OutlineItem, ParsedArticle } from './types' @@ -18,7 +19,8 @@ export function parseArticle(raw: string): ParsedArticle { } export function markdownToHtml(markdown: string): string { - return addHeadingAnchors(marked.parse(markdown, { async: false }) as string) + const rawHtml = marked.parse(markdown, { async: false }) as string + return sanitizeMarkdownHtml(addHeadingAnchors(rawHtml)) } export function getArticleStats(raw: string): ArticleStats { @@ -61,6 +63,7 @@ export function buildReadingHtml(raw: string): string { + ${escapeHtml(title)}