diff --git a/scripts/build.cmd b/scripts/build.cmd index 4edd58d..d31a3f6 100644 --- a/scripts/build.cmd +++ b/scripts/build.cmd @@ -20,7 +20,7 @@ if "!INPUT_VER!"=="" set INPUT_VER=!PKG_VER! echo. echo ======================================== echo Building MD++ v!INPUT_VER! -echo Targets: nsis + msi +echo Targets: nsis echo ======================================== echo. @@ -35,7 +35,7 @@ if /i not "!CONFIRM!"=="y" ( :: Step 1: Sync version :: ============================================ echo. -echo [1/3] Syncing version (source: package.json)... +echo [1/2] Syncing version (source: package.json)... if not "!INPUT_VER!"=="!PKG_VER!" ( echo Updating package.json: !PKG_VER! -^> !INPUT_VER! @@ -54,7 +54,7 @@ echo All files synced to v!INPUT_VER! :: Step 2: Build app + NSIS installer :: ============================================ echo. -echo [2/3] Building app + NSIS installer... +echo [2/2] Building app + NSIS installer... :: clean old nsis output to avoid conflicts if exist "src-tauri\target\release\bundle\nsis" rmdir /s /q "src-tauri\target\release\bundle\nsis" 2>nul @@ -72,83 +72,19 @@ if "!OUT_NSIS!"=="" ( ) if "!OUT_NSIS!"=="" ( + echo. echo ERROR: NSIS build failed (exit code: %BUILD_ERR%). echo No .exe found in bundle\nsis\ + echo. pause exit /b 1 ) -echo NSIS: !OUT_NSIS! -:: ============================================ -:: Step 3: Build MSI installer (manual WiX) -:: ============================================ -echo. -echo [3/3] Building MSI installer... - -set WIX_DIR=%LOCALAPPDATA%\tauri\WixTools314 -if not exist "%WIX_DIR%\light.exe" ( - echo WiX tools not found at %WIX_DIR%\light.exe - echo Skipping MSI. Install Tauri CLI to enable MSI builds. - goto :done -) - -set BUNDLE_DIR=src-tauri\target\release -set WIX_SRC=%BUNDLE_DIR%\wix\x64 - -:: clean old wix output -if exist "%BUNDLE_DIR%\wix" rmdir /s /q "%BUNDLE_DIR%\wix" 2>nul -if exist "%BUNDLE_DIR%\bundle\msi" rmdir /s /q "%BUNDLE_DIR%\bundle\msi" 2>nul - -:: let Tauri generate WiX source files (app already built, this is fast) -echo Generating WiX source files... -call pnpm tauri build --bundles msi >nul 2>&1 +:: resolve absolute path of the installer +for %%I in ("!OUT_NSIS!") do set OUT_NSIS_ABS=%%~fI -if not exist "%WIX_SRC%\main.wxs" ( - echo ERROR: main.wxs not generated. Skipping MSI. - goto :done -) - -:: patch desktop shortcut to use ProductIcon explicitly -echo Patching desktop shortcut icon... -node scripts\patch-wxs.mjs "%WIX_SRC%\main.wxs" 2>nul - -:: ensure output dir exists and no stale .wixobj -if not exist "%WIX_SRC%" mkdir "%WIX_SRC%" -if exist "%WIX_SRC%\main.wixobj" del "%WIX_SRC%\main.wixobj" - -:: run candle.exe (compile .wxs -> .wixobj) -echo Running candle.exe... -"%WIX_DIR%\candle.exe" -arch x64 -dBuildVersion="!INPUT_VER!" -out "%WIX_SRC%\\" "%WIX_SRC%\main.wxs" -if %ERRORLEVEL% neq 0 ( - echo ERROR: candle.exe failed! - pause - exit /b %ERRORLEVEL% -) - -:: fix locale codepage for Chinese (936 instead of 1252) -set WXL=%WIX_SRC%\locale.wxl -if exist "%WXL%" ( - echo Fixing locale codepage to 936... - node scripts\fix-locale.mjs "%WXL%" 2>nul -) - -:: run light.exe (link .wixobj -> .msi) -echo Running light.exe... -set OUT_MSI=%BUNDLE_DIR%\bundle\msi\md++_!INPUT_VER!_x64_en-US.msi -if not exist "%BUNDLE_DIR%\bundle\msi" mkdir "%BUNDLE_DIR%\bundle\msi" - -"%WIX_DIR%\light.exe" ^ - -ext "%WIX_DIR%\WixUIExtension.dll" ^ - -loc "%WXL%" ^ - -out "%OUT_MSI%" ^ - "%WIX_SRC%\main.wixobj" - -if %ERRORLEVEL% neq 0 ( - echo ERROR: light.exe failed! - pause - exit /b %ERRORLEVEL% -) -echo MSI: %OUT_MSI% +echo. +echo NSIS: !OUT_NSIS_ABS! :: ============================================ :: Done @@ -157,9 +93,30 @@ echo MSI: %OUT_MSI% echo. echo ======================================== echo Build complete: v%INPUT_VER% -if not "!OUT_NSIS!"=="" echo NSIS: !OUT_NSIS! -if exist "%OUT_MSI%" echo MSI: %OUT_MSI% +if not "!OUT_NSIS_ABS!"=="" ( + echo. + echo Output: !OUT_NSIS_ABS! +) echo ======================================== +echo. +echo Press ENTER to open the output folder, or ESC to exit... + +:: 用 PowerShell 读取单次按键:Enter -> exit 0, Escape -> exit 1, 其它 -> exit 2 +powershell -NoProfile -Command "$k=[Console]::ReadKey($true).Key; if($k -eq 'Enter'){exit 0}elseif($k -eq 'Escape'){exit 1}else{exit 2}" >nul 2>&1 +set KEY_ERR=!ERRORLEVEL! + +if "!KEY_ERR!"=="0" goto open_folder +if "!KEY_ERR!"=="1" goto end +echo Unrecognized key, ignoring. +goto end + +:open_folder +if not "!OUT_NSIS_ABS!"=="" ( + explorer.exe /select,"!OUT_NSIS_ABS!" + echo Folder opened. +) else ( + echo No output file to open. +) -pause +:end endlocal diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 23e8972..631b753 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -93,6 +93,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-opener", "tokio", + "windows", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fb56e7b..3465e29 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,3 +29,9 @@ notify-debouncer-mini = "0.4" base64 = "0.22" dirs = "5" tokio = { version = "1", features = ["time"] } + +# Windows 文件关联:删除残留 UserChoice 后用 SHChangeNotify 刷新 Shell 关联与图标缓存。 +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = [ + "Win32_UI_Shell", +] } diff --git a/src-tauri/src/commands/opener.rs b/src-tauri/src/commands/opener.rs index 47270c1..2b15776 100644 --- a/src-tauri/src/commands/opener.rs +++ b/src-tauri/src/commands/opener.rs @@ -1,10 +1,16 @@ #[cfg(target_os = "windows")] use std::ffi::OsString; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(target_os = "macos")] use std::io::Write; +/// 禁止子进程弹出控制台黑窗(仅 Windows 生效) +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + /// 用系统默认程序打开任意文件(绕过 opener plugin 的路径 ACL) /// 适用于打开 app data dir 下的临时导出文件等不受 opener plugin 信任的路径 #[tauri::command] @@ -23,6 +29,7 @@ fn open_file(path: &Path) -> Result<(), String> { Command::new("cmd") .args(["/C", "start", "", "/B"]) .arg(path) + .creation_flags(CREATE_NO_WINDOW) .spawn() .map_err(|e| format!("启动关联程序失败: {}", e))?; Ok(()) @@ -59,20 +66,16 @@ pub async fn open_containing_folder(path: String) -> Result<(), String> { } /// 检查 md++ 是否为 .md 文件的默认打开程序 +/// +/// 与「双击实际行为」对齐:解析 UserChoice → ProgID → shell\open\command → exe 路径, +/// 并与当前 exe 比对,避免「残留字符串在但 ProgID 已删」这类误报。 #[tauri::command] pub async fn check_file_association() -> Result { #[cfg(target_os = "windows")] { - // 检查注册表:HKCU\Software\Classes\.md 的默认值是否为 "MdppMarkdown" - let output = Command::new("reg") - .args(["query", r"HKCU\Software\Classes\.md", "/ve"]) - .output() - .map_err(|e| format!("查询注册表失败: {}", e))?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - return Ok(stdout.contains("MdppMarkdown")); - } - return Ok(false); + tauri::async_runtime::spawn_blocking(check_file_association_windows) + .await + .map_err(|e| format!("检测默认程序失败: {}", e))? } #[cfg(target_os = "macos")] @@ -87,46 +90,20 @@ pub async fn check_file_association() -> Result { } /// 将 md++ 注册为 .md / .markdown 文件的默认打开方式 +/// +/// 两步走: +/// 1. 用 `reg add` 写入 ProgID 定义 (MdppMarkdown)、OpenWithProgids、右键菜单、 +/// RegisteredApplications/Capabilities —— 让 md++ 出现在「打开方式」与系统默认应用列表。 +/// 2. 用系统 COM API `IApplicationAssociationRegistration::SetAppAsDefault` 写入 +/// 带 Hash 校验的 UserChoice 键 —— 这一步决定双击时实际打开哪个程序, +/// 无法用 `reg add` 伪造(Hash 算法非公开且会变动)。 #[tauri::command] pub async fn register_file_association() -> Result<(), String> { #[cfg(target_os = "windows")] { - let exe_path = std::env::current_exe() - .map_err(|e| format!("获取程序路径失败: {}", e))?; - let exe_str = exe_path.to_string_lossy().to_string(); - - let commands = [ - // 右键菜单"用 md++ 打开" - (r"HKCU\Software\Classes\.md\shell\OpenWithMdpp", "/ve", "用 md++ 打开"), - (r"HKCU\Software\Classes\.md\shell\OpenWithMdpp\command", "/ve", &format!("\"{}\" \"%1\"", exe_str)), - (r"HKCU\Software\Classes\.markdown\shell\OpenWithMdpp", "/ve", "用 md++ 打开"), - (r"HKCU\Software\Classes\.markdown\shell\OpenWithMdpp\command", "/ve", &format!("\"{}\" \"%1\"", exe_str)), - // 默认打开方式 - (r"HKCU\Software\Classes\.md", "/ve", "MdppMarkdown"), - (r"HKCU\Software\Classes\MdppMarkdown", "/ve", "Markdown 文件"), - (r"HKCU\Software\Classes\MdppMarkdown\DefaultIcon", "/ve", &format!("\"{}\",0", exe_str)), - (r"HKCU\Software\Classes\MdppMarkdown\shell\open\command", "/ve", &format!("\"{}\" \"%1\"", exe_str)), - (r"HKCU\Software\Classes\.markdown", "/ve", "MdppMarkdown"), - ]; - - for (key, value_name, data) in &commands { - let mut cmd = Command::new("reg"); - cmd.arg("add").arg(key); - if *value_name == "/ve" { - cmd.arg("/ve"); - } else { - cmd.arg("/v").arg(value_name); - } - cmd.arg("/d").arg(data) - .arg("/f"); - let output = cmd.output().map_err(|e| format!("执行注册表命令失败: {}", e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("注册表写入失败: {}", stderr)); - } - } - - Ok(()) + tauri::async_runtime::spawn_blocking(register_file_association_windows) + .await + .map_err(|e| format!("注册默认程序失败: {}", e))? } #[cfg(target_os = "macos")] @@ -283,12 +260,269 @@ if let appURL = NSWorkspace.shared.urlForApplication(toOpen: testFile) {{ Ok(stdout.trim() == "true") } +/// 读取 HKCU 注册表某键的默认值(REG_SZ)。键不存在或无值时返回 None。 +#[cfg(target_os = "windows")] +fn reg_query_default(key: &str) -> Option { + let output = Command::new("reg") + .args(["query", key, "/ve"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + if !output.status.success() { + return None; + } + // 输出形如: + // HKEY_CURRENT_USER\...\key + // (默认) REG_SZ MdppMarkdown + // 中文系统下默认值名是 "(默认)",英文是 "(Default)",按 REG_SZ 行提取最稳。 + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let line = line.trim(); + if line.contains("REG_SZ") { + if let Some(idx) = line.find("REG_SZ") { + let val = line[idx + "REG_SZ".len()..].trim(); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + } + None +} + +/// 从 `"" "%1"` 形式的 command 字符串中提取 exe 路径。 +/// 处理带引号/不带引号两种写法,忽略 `%1` 等参数。 +#[cfg(target_os = "windows")] +fn extract_exe_from_command(cmd: &str) -> Option { + let cmd = cmd.trim(); + let exe = if let Some(rest) = cmd.strip_prefix('"') { + rest.split('"').next()? + } else { + cmd.split_whitespace().next()? + }; + Some(PathBuf::from(exe)) +} + +/// 规范化两个 exe 路径后比较是否指向同一文件(大小写不敏感、忽略 \\?\ 前缀)。 +#[cfg(target_os = "windows")] +fn exe_paths_match(a: &Path, b: &Path) -> bool { + let canon = |p: &Path| { + p.to_string_lossy() + .trim_start_matches(r"\\?\") + .trim_start_matches(r"\??\") + .to_lowercase() + }; + canon(a) == canon(b) +} + +/// Windows 检测:完整解析 UserChoice → ProgID → shell\open\command → exe 路径, +/// 与当前 exe 比对。任一环缺失即返回 false。 +#[cfg(target_os = "windows")] +fn check_file_association_windows() -> Result { + let current_exe = std::env::current_exe() + .map_err(|e| format!("获取程序路径失败: {}", e))?; + + for ext in [".md", ".markdown"] { + // 1. 优先读 UserChoice.ProgId(决定双击行为的键) + let user_choice_key = format!( + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\{}\UserChoice", + ext + ); + let mut progid = reg_query_default(&user_choice_key); + // 默认值名是 ProgId,上面读的是 (默认),可能为空 —— 再按值名 ProgId 读一次 + if progid.is_none() { + progid = reg_query_named(&user_choice_key, "ProgId"); + } + + // 2. 回退读旧式关联 HKCU\Software\Classes\.ext 默认值 + if progid.is_none() { + progid = reg_query_default(&format!(r"HKCU\Software\Classes\{}", ext)); + } + + let progid = match progid { + Some(p) if !p.is_empty() => p, + _ => return Ok(false), + }; + + // 3. 解析 ProgID 的 shell\open\command 默认值,提取 exe 路径 + let command_key = format!(r"HKCU\Software\Classes\{}\shell\open\command", progid); + let command = match reg_query_default(&command_key) { + Some(c) if !c.is_empty() => c, + _ => return Ok(false), + }; + let assoc_exe = match extract_exe_from_command(&command) { + Some(p) => p, + None => return Ok(false), + }; + + // 4. 与当前 exe 比对 + if !exe_paths_match(&assoc_exe, ¤t_exe) { + return Ok(false); + } + } + Ok(true) +} + +/// 读取注册表某键下指定值名(REG_SZ)。键不存在或无值时返回 None。 +#[cfg(target_os = "windows")] +fn reg_query_named(key: &str, value_name: &str) -> Option { + let output = Command::new("reg") + .args(["query", key, "/v", value_name]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let line = line.trim(); + if line.contains("REG_SZ") { + if let Some(idx) = line.find("REG_SZ") { + let val = line[idx + "REG_SZ".len()..].trim(); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + } + None +} + +/// 写入一条注册表字符串值(HKCU)。失败返回中文错误。 +#[cfg(target_os = "windows")] +fn reg_add_str(key: &str, value_name: Option<&str>, data: &str) -> Result<(), String> { + let mut cmd = Command::new("reg"); + cmd.arg("add").arg(key); + match value_name { + Some(name) => { + cmd.arg("/v").arg(name); + } + None => { + cmd.arg("/ve"); + } + } + cmd.arg("/d").arg(data).arg("/f"); + cmd.creation_flags(CREATE_NO_WINDOW); + let output = cmd.output().map_err(|e| format!("执行注册表命令失败: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("注册表写入失败: {}", stderr)); + } + Ok(()) +} + +/// Windows 注册:写入 ProgID 定义链并清除残留 UserChoice,使双击回退到我们的 ProgID。 +/// +/// 不使用 `IApplicationAssociationRegistration::SetAppAsDefault`:该 API 在 +/// Win10 1803+ 因 UserChoice Hash 校验加强而返回 E_FAIL (0x80004005),且 Hash 算法 +/// 非公开无法伪造。可靠做法是删除 UserChoice 键,让 Explorer 回退到 +/// `HKCR\.ext → ProgID` 映射(由步骤 1 写入)。 +#[cfg(target_os = "windows")] +fn register_file_association_windows() -> Result<(), String> { + use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST}; + + let exe_path = std::env::current_exe() + .map_err(|e| format!("获取程序路径失败: {}", e))?; + let exe_str = exe_path.to_string_lossy().to_string(); + let open_cmd = format!("\"{}\" \"%1\"", exe_str); + let icon_cmd = format!("\"{}\",0", exe_str); + + // --- 步骤 1:写入 ProgID 定义、扩展名默认值、OpenWithProgids、右键菜单、Capabilities --- + // ProgID:MdppMarkdown + reg_add_str(r"HKCU\Software\Classes\MdppMarkdown", None, "Markdown 文件")?; + reg_add_str( + r"HKCU\Software\Classes\MdppMarkdown", + Some("FriendlyTypeName"), + "Markdown 文件", + )?; + reg_add_str(r"HKCU\Software\Classes\MdppMarkdown\DefaultIcon", None, &icon_cmd)?; + reg_add_str(r"HKCU\Software\Classes\MdppMarkdown\shell", None, "open")?; + reg_add_str( + r"HKCU\Software\Classes\MdppMarkdown\shell\open\command", + None, + &open_cmd, + )?; + + // 扩展名默认值 + OpenWithProgids + 右键菜单(.md / .markdown) + for ext in [".md", ".markdown"] { + let ext_key = format!(r"HKCU\Software\Classes\{}", ext); + reg_add_str(&ext_key, None, "MdppMarkdown")?; + reg_add_str( + &format!(r"{}\OpenWithProgids", ext_key), + Some("MdppMarkdown"), + "", + )?; + reg_add_str( + &format!(r"{}\shell\OpenWithMdpp", ext_key), + None, + "用 md++ 打开", + )?; + reg_add_str( + &format!(r"{}\shell\OpenWithMdpp\command", ext_key), + None, + &open_cmd, + )?; + } + + // Capabilities:让 md++ 出现在系统「默认应用」列表 + reg_add_str( + r"HKCU\Software\md++\Capabilities", + Some("ApplicationName"), + "md++", + )?; + reg_add_str( + r"HKCU\Software\md++\Capabilities", + Some("ApplicationDescription"), + "Windows 轻量便捷的 Markdown 阅读器", + )?; + reg_add_str( + r"HKCU\Software\md++\Capabilities\FileAssociations", + Some(".md"), + "MdppMarkdown", + )?; + reg_add_str( + r"HKCU\Software\md++\Capabilities\FileAssociations", + Some(".markdown"), + "MdppMarkdown", + )?; + reg_add_str( + r"HKCU\Software\RegisteredApplications", + Some("md++"), + "Software\\md++\\Capabilities", + )?; + + // --- 步骤 2:删除残留 UserChoice,使 Explorer 回退到上面的 .ext → ProgID 映射 --- + // UserChoice 带 Hash 校验,无法用 reg add 伪造;直接删除整个键,双击即回退到 + // HKCR\.ext 默认值指向的 ProgID(MdppMarkdown),这是我们写入的可信链。 + for ext in [".md", ".markdown"] { + let user_choice_key = format!( + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\{}\UserChoice", + ext + ); + // /f 忽略「键不存在」错误;忽略执行失败(键可能本就不存在) + let _ = Command::new("reg") + .args(["delete", &user_choice_key, "/f"]) + .creation_flags(CREATE_NO_WINDOW) + .status(); + } + + // --- 步骤 3:刷新 Shell 关联与图标缓存 --- + // SHCNE_ASSOCCHANGED 通知资源管理器重读关联;若不刷新,旧图标/旧默认可能继续缓存。 + unsafe { + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None); + } + + Ok(()) +} + #[cfg(target_os = "windows")] fn reveal_path(path: &Path) -> Result<(), String> { let mut select_arg = OsString::from("/select,"); select_arg.push(path.as_os_str()); Command::new("explorer.exe") .arg(select_arg) + .creation_flags(CREATE_NO_WINDOW) .spawn() .map_err(|e| format!("启动资源管理器失败: {}", e))?; Ok(()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 062d064..bfc9254 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,7 +33,6 @@ "active": true, "targets": [ "dmg", - "msi", "nsis" ], "icon": [ diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx index 449f3f6..56fb6ee 100644 --- a/src/components/SearchBar/SearchBar.tsx +++ b/src/components/SearchBar/SearchBar.tsx @@ -50,7 +50,7 @@ export function SearchBar({ contentRef }: SearchBarProps) { const total = highlightMatches(el, keyword); if (total > 0) { const marks = Array.from(el.querySelectorAll('mark.search-mark')); - focusMatch(marks, 0); + focusMatch(marks, 0, el); setResult(total, 0); } else { setResult(0, 0); @@ -63,7 +63,7 @@ export function SearchBar({ contentRef }: SearchBarProps) { const el = contentRef.current; if (!el || total === 0) return; const marks = Array.from(el.querySelectorAll('mark.search-mark')); - focusMatch(marks, current); + focusMatch(marks, current, el); }, [current, total, contentRef]); const clearKeyword = () => { diff --git a/src/features/markdown/code-features.ts b/src/features/markdown/code-features.ts index 1bb78dd..f2fcc56 100644 --- a/src/features/markdown/code-features.ts +++ b/src/features/markdown/code-features.ts @@ -31,9 +31,14 @@ export function setupCodeBlockTitle(md: MarkdownIt): void { // 构建代码块 HTML let html = ''; - // 处理行标记 - const lines = token.content.split('\n'); - const lineMarks = parseLineMarks(lines); + // 解析行标记 + const rawLines = token.content.split('\n'); + // markdown-it 的 fence token.content 末尾固定带一个 \n, + // 去掉 split 产生的空串,避免 cleanContent 末尾多出空行 + if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') { + rawLines.pop(); + } + const lineMarks = parseLineMarks(rawLines); // 将行标记序列化为 data 属性,供高亮后注入样式 const marksAttr = lineMarks.length > 0 @@ -47,10 +52,10 @@ export function setupCodeBlockTitle(md: MarkdownIt): void { html += `
\n`; } - // 移除标记注释,生成干净的代码(支持 // 和 # 风格) - const cleanContent = lines - .map((line) => line.replace(/\s*(?:\/\/|#)\s*\[!code\s+(highlight|focus|error|warning|\+\+|--)\]\s*$/, '')) - .join('\n'); + // 移除标记注释,生成干净的代码(支持 // 和 # 风格) + const cleanContent = rawLines + .map((line) => line.replace(/\s*(?:\/\/|#)\s*\[!code\s+(highlight|focus|error|warning|\+\+|--)\]\s*$/, '')) + .join('\n'); // 生成带语言的代码块 const langClass = lang ? ` class="language-${lang}"` : ''; diff --git a/src/features/markdown/highlight.ts b/src/features/markdown/highlight.ts index a00900f..7d0ac2c 100644 --- a/src/features/markdown/highlight.ts +++ b/src/features/markdown/highlight.ts @@ -9,7 +9,22 @@ async function getHL(): Promise { if (!highlighterPromise) { highlighterPromise = createHighlighter({ themes: ['github-light', 'github-dark'], - langs: ['typescript', 'javascript', 'bash', 'json', 'markdown', 'rust', 'python'], + langs: [ + 'typescript', 'javascript', 'bash', 'json', 'markdown', 'rust', 'python', + // 常用文档语言,按需添加避免 bundle 膨胀 + 'sql', // SQL / MySQL / PostgreSQL + 'shellscript', // shell / sh / bash / zsh + 'css', // CSS + 'html', // HTML + 'xml', // XML + 'yaml', // YAML + 'toml', // TOML + 'diff', // Diff + 'dockerfile', // Dockerfile + 'ini', // INI / properties / conf + 'java', // Java + 'go', // Go + ], engine: createJavaScriptRegexEngine(), }); } @@ -98,25 +113,24 @@ export async function highlightCodeBlocks(html: string, theme: ThemeName): Promi if (!lang) return fullMatch; if (lang === 'mermaid') return fullMatch; - const code = decodeHtmlEntities(encoded); - try { - const loaded = hl.getLoadedLanguages(); - if (!loaded.includes(lang)) { - return fullMatch.replace( - /
/,
-          `
`,
-        );
-      }
-      const shikiResult = hl.codeToHtml(code, { lang, theme: themes[theme] });
-      // 保留 data-line-marks 属性
-      const patchedWrapper = fullMatch.replace(/
[\s\S]*?<\/code><\/pre>/, shikiResult);
+	    const code = decodeHtmlEntities(encoded);
+	    try {
+	      // 不先用 getLoadedLanguages() 过滤:shiki 内部会解析语言别名
+	      //(如 shell → shellscript, zsh → shellscript),提前过滤反而误杀
+	      const shikiResult = hl.codeToHtml(code, { lang, theme: themes[theme] });
+	      // 去除 shiki 自带的 background-color(inline style 会覆盖 prose.css 的 --code-bg),
+	      // 让所有代码块统一使用 CSS 变量定义的背景色
+	      const noBgResult = shikiResult.replace(
+	        /\bbackground-color:\s*[^;"]+;?\s*/g,
+	        '',
+	      );
+	      // 保留 data-line-marks 属性
+	      const patchedWrapper = fullMatch.replace(/
[\s\S]*?<\/code><\/pre>/, noBgResult);
       return patchedWrapper;
-    } catch {
-      return fullMatch.replace(
-        /
/,
-        `
`,
-      );
-    }
+	    } catch {
+	      // 高亮失败时直接返回原样,不再加 pre.shiki 确保现有样式(行号/背景)仍然正确
+	      return fullMatch;
+	    }
   });
 
   // 在 shiki 生成 .line 之后注入行标记样式
diff --git a/src/features/search/search.ts b/src/features/search/search.ts
index 6a1e554..2d11930 100644
--- a/src/features/search/search.ts
+++ b/src/features/search/search.ts
@@ -65,11 +65,15 @@ export function clearHighlights(root: HTMLElement): void {
   root.normalize();
 }
 
-/** 聚焦第 index 个匹配项(滚动到视图 + 高亮) */
-export function focusMatch(marks: HTMLElement[], index: number): void {
+/** 聚焦第 index 个匹配项(滚动到视图 + 高亮)
+ * @param marks 所有匹配的 mark 元素数组
+ * @param index 目标匹配项索引
+ * @param container 可选,显式指定滚动容器(推荐传入,避免 findScrollParent 误匹配到内部可滚动元素)
+ */
+export function focusMatch(marks: HTMLElement[], index: number, container?: HTMLElement | null): void {
   marks.forEach((m, i) => m.classList.toggle(MARK_CURRENT_CLASS, i === index));
   const mark = marks[index];
   if (!mark) return;
 
-  smoothScrollToElement(mark, null, 'center');
+  smoothScrollToElement(mark, container, 'center');
 }
diff --git a/src/utils/smoothScroll.ts b/src/utils/smoothScroll.ts
index fcf6eaa..7e8ce7c 100644
--- a/src/utils/smoothScroll.ts
+++ b/src/utils/smoothScroll.ts
@@ -7,6 +7,17 @@ function easeInOutCubic(t: number): number {
   return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
 }
 
+/** 追踪每个滚动容器的活跃动画 ID,新动画自动取消旧动画 */
+const activeAnimations = new WeakMap();
+
+function cancelAnimation(element: HTMLElement): void {
+  const id = activeAnimations.get(element);
+  if (id != null) {
+    cancelAnimationFrame(id);
+    activeAnimations.delete(element);
+  }
+}
+
 /**
  * 平滑滚动到指定位置
  * @param element 滚动容器
@@ -18,6 +29,9 @@ export function smoothScrollTo(
   top: number,
   duration = 200,
 ): void {
+  // 取消该容器上正在进行的动画,防止多个动画竞争
+  cancelAnimation(element);
+
   const start = element.scrollTop;
   const delta = top - start;
   if (Math.abs(delta) < 1) return;
@@ -31,11 +45,15 @@ export function smoothScrollTo(
     element.scrollTop = start + delta * eased;
 
     if (progress < 1) {
-      requestAnimationFrame(step);
+      const id = requestAnimationFrame(step);
+      activeAnimations.set(element, id);
+    } else {
+      activeAnimations.delete(element);
     }
   }
 
-  requestAnimationFrame(step);
+  const id = requestAnimationFrame(step);
+  activeAnimations.set(element, id);
 }
 
 /**