fix(audit): 审计问题全量修复(docs/12)

高危逻辑 A1-A10:saved 写盘快照、重载卸载回写闸门与期望值校验、
?file 无条件打开、偏好读后才写+防抖、depth=0 守卫、查找命中随编辑
作废、自动保存避让冲突标签、搜索防链接环、重 IO 命令异步化+上限。
样式:--panel 更正为 --bg-panel、新增 .input 基础样式、文档主题
纸面/字色成对、打印隐藏清单补全、paperTone 驱动源码明暗。
性能:状态栏/大纲按正文 memo、大纲延后提取、Sidebar memo、专注
装饰映射复用、查找防抖、图片 base64 过 IPC。
中危一批:关窗落盘后放行、回收站去向明示、GBK 搜索、create_new
落盘、Mermaid 渲染代次守卫、多行粘贴分段等。
回归:新增 tabkey/audit-fix 集成测试(E1 Tab 抢先定性排除),
vitest 71 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-13 23:34:19 +08:00
parent 78fbf6a91b
commit db81e60d71
26 changed files with 1064 additions and 228 deletions

View File

@@ -0,0 +1,64 @@
# 审计修复报告(对应 docs/11,2026-11-13 晚)
按用户拍板顺序执行:高危逻辑 → 样式 → 性能,并顺带处理中危中的低成本项。
验证:`npx tsc -b` 0 错误;`npx vitest run` **71 通过 + 2 跳过**(修复前 61+2,新增回归 10 例:tabs 1、tabkey 2、audit-fix 7);`npm run build` 通过;dev 冒烟 200;Rust 侧 search/base64 以独立 crate 实跑断言(符号链接环终止、GBK 中文可搜、base64 往返);commands.rs 过 rustfmt 语法解析。本地未推送。
## 一、高危逻辑(A1–A10)——全部已修
| # | 修复 |
|---|---|
| A1 保存竞态 | `saved` action 改为携带写盘快照 `savedContent`;saveTab 用调用时刻快照落盘并回填。写盘期间的新输入保持脏。回归测试已加(tabs.test)。 |
| A2 重载被卸载回写覆盖 | EditorPane 新增 `onFinalMarkdown` 通道专门承接卸载回写;重载活动标签前 App 先把该标签加入抑制集合并 1.5 秒兜底清除,回写被丢弃。 |
| A3 重载检查—执行竞态 | reloadTabFromDisk 增加 `expectedContent` 校验与 isDirty 复检,读取期间状态已变则放弃重载、交下一轮按冲突处理。 |
| A4 ?file= 被挡死 | 启动 effect 重构:恢复计划改为条件块,`?file=` 打开移到无条件路径;多窗口主路径恢复。 |
| A5 偏好启动竞态 | 偏好改为最先读回,读完置 `uiLoadedRef` 后 `[ui]` 保存 effect 才允许写库;落库再加 400ms 防抖(顺带解决自定义 CSS 逐字写库)。 |
| A6 depth=0 RangeError | cursorFromSelection 对 depth=0 单独处理(行=起点前文本块数+1、列=1),并顺手给常规路径加提前终止。回归测试覆盖全选与公式块 NodeSelection。 |
| A7 查找命中过期 | 编辑器/源码两侧:文档一变立即作废命中表,下个查找操作先按新内容重算;同时统一了替换契约——pane 内不再自查一遍,刷新与定位由 App 层单次完成(修掉 B 的双重计算与序号错位)。 |
| A8 自动保存覆盖外部修改 | 自动保存跳过正处于冲突弹窗中的标签。 |
| A9 搜索无限递归 | search.rs 按规范化路径去重(链接环直接跳过)+ 深度上限 24 + 已扫描文件上限 20000。独立 crate 验证链接环可终止。 |
| A10 主线程同步命令 | read_text_file / write_text_file / read_file_base64 / workspace_search 改 async + spawn_blocking;文本读 64MB、base64 读 32MB 上限。 |
## 二、中危顺带修复
- 关窗恢复写入改为先 preventDefault、等全部 saveRecovery 落盘再决定放行/弹窗(原 fire-and-forget 丢未命名草稿)。
- 另存为后新旧两个恢复键都清理(原只清新键,启动误报草稿)。
- 全局快捷键在任一弹窗打开时整体让行(anyModalRef)。
- 打印在源码模式下改为等渲染编辑器就绪再打印(原固定 60ms 赛跑)。
- 原子写临时名加纳秒后缀(并发保存不共用临时文件);create_text_file 改 create_new 原子创建;资源落盘改 create_new 循环(消除 dedupe 竞态与兜底覆盖)。
- 删除到回收站返回实际去向,前端对永久删除明确提示。
- 编码:BOM+坏字节不再 lossy 吞字(退 GBK);搜索改为 GBK 感知解码(GBK 文档中文可搜,独立 crate 验证)。
- DB 锁中毒自愈(into_inner 取回连接);文件名清洗补控制字符/结尾点空格/Windows 保留名。
- 图片粘贴改 base64 过 IPC(原 number[] 数组);落盘失败有错误提示(uploader try/catch + onImageError)。
- 快捷键:录入无修饰键组合直接拦截并提示;`Ctrl+Shift+=` 归一为 `ctrl+=`(与缩放默认键对齐);源码模式切主题改 Compartment 热切换(不丢光标/撤销史);源码字号跟随缩放变量。
- 导出:坏公式不再整篇失败(throwOnError:false);Mermaid 导出 id 加序号防重复;导出图片解析去查询串 + URL 解码。
- 工作区搜索:结果记住搜索时的词(点击不再用输入框现值)、换根目录清旧结果。
- 粘贴多行纯文本按段落插入(原塞单文本节点、往返不保真,回归测试已加)。
- Mermaid 视图渲染代次守卫(旧渲染不覆盖新预览);锚点兜底跳过空段落;list_item 视图闭包节点更新 + label 刷新;代码块语言类名同步;setAlignColumn 恢复完整选区;代码块 Tab 多行选区整体缩进;表格 Tab 命令失败不吞键;Stage5 弹窗补背景点击/Esc 关闭;docOverrides/conflictSeen 随标签关闭回收。
## 三、样式(D)——高危三项全修
- `--panel` → `--bg-panel`(3 处,查找条不再透明)。
- 新增 `.input` 基础样式(边框/背景/字色/聚焦环,全主题变量),查找框/搜索框/弹窗输入框告别原生外观。
- notion 补 `--doc-ink:#37352f`,github 补 `--doc-paper:#ffffff` + `--doc-ink:#1f2328`,深色 UI 下也可读。
- 打印隐藏清单补 `.findbar`、`.mermaid-head`;`.modal-note` 重复定义合并;文件末重复规则删除;新增全应用细滚动条与选区色;FindBar min-width 340 + max-width 兜底。
- themes.ts 的死字段 `darkPaper` 升级为真正被消费的 `paperTone`:源码模式明暗按文档主题纸面决定(修 D5 源码/文档主题不联动)。
## 四、性能(C)
- 状态栏计数与大纲改为只随正文变化重算(useMemo 按 content),大纲再经 useDeferredValue 延后提取;Sidebar memo 化(App 传稳定回调);tabs reducer 对同内容 updateContent 直接返回原 state。
- 专注装饰改「映射复用」:活动块不变时复用映射后的旧装饰集,只在跨块/强制时重建;顺带修 depth=0 选中顶层原子时整篇变淡。
- 查找输入防抖 150ms(Enter/翻页立即冲刷挂起查找)。
- 选区行列计算提前终止(不再每次选区变化数完整篇)。
- 图片 IPC 改 base64(见中危)。
## 五、待核实项结论
- **E1 Tab 抢先:排除。** 新增 tests/tabkey.test.ts 向组装好的编辑器派发真实 Tab:表格内跳格、文档未变;代码块行首缩进、非光标处插入。两项行为正确,indent 插件未抢先。此测试常驻回归。
- E3/E4(KaTeX 抛错、Mermaid 导出 id)已按预防性修复处理(见中危)。
- E2 CSP 维持现状(需结合真机 WebView 行为专项评估,不在本批)。
- E5 双进程同开一个 SQLite 的 SQLITE_BUSY 未处理:同进程多窗口共用 State 连接无此问题;双开进程属边缘场景,列入阶段 7 真机清单观察。
## 六、本批未动的(架构性/需真机)
- C2:Milkdown listener 每 200ms 全量序列化 + 深比较是大文档输入的固有成本,根治需把「状态持有 Markdown 字符串」改为按需序列化,属架构级改动,建议单独立项评估,不在本批冒险。
- 整套 Tauri 构建与真机行为(多窗口、关窗拦截、回收站去向提示、打印)仍待 Windows 验证——本批所有 Rust 改动只过了语法与纯逻辑验证,未过完整 cargo 构建(沙箱限制,同前)。

View File

@@ -20,8 +20,23 @@ pub struct FileContent {
pub had_bom: bool,
}
/// 审计修复:重 IO 命令改 async 并在阻塞线程池执行,避免占用 UI 主线程;
/// 同时加大小上限,误开超大文件直接报错而不是把内存吃爆。
#[tauri::command]
pub fn read_text_file(path: String) -> Result<FileContent, String> {
pub async fn read_text_file(path: String) -> Result<FileContent, String> {
tauri::async_runtime::spawn_blocking(move || read_text_file_sync(path))
.await
.map_err(|e| format!("读取任务失败:{e}"))?
}
const MAX_TEXT_FILE_BYTES: u64 = 64 * 1024 * 1024;
fn read_text_file_sync(path: String) -> Result<FileContent, String> {
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > MAX_TEXT_FILE_BYTES {
return Err("文件过大(超过 64MB),已拒绝打开".to_string());
}
}
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
let (content, encoding, had_bom) = decode_bytes(&bytes);
let line_ending = if content.contains("\r\n") {
@@ -40,11 +55,12 @@ pub fn read_text_file(path: String) -> Result<FileContent, String> {
fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return (
String::from_utf8_lossy(rest).into_owned(),
"UTF-8".into(),
true,
);
if let Ok(s) = std::str::from_utf8(rest) {
return (s.to_string(), "UTF-8".into(), true);
}
// 带 BOM 但正文不是合法 UTF-8:按 GBK 解码,避免 lossy 把坏字节永久替换成 <20>
let (text, _, _) = encoding_rs::GBK.decode(rest);
return (text.into_owned(), "GBK".into(), true);
}
if bytes.starts_with(&[0xFF, 0xFE]) {
let (text, _, _) = encoding_rs::UTF_16LE.decode(bytes);
@@ -65,7 +81,13 @@ fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
/// 原子写:先写同目录临时文件并落盘,再替换目标。`keep_bom` 为真时补回 UTF-8 BOM。
#[tauri::command]
pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> {
pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || write_text_file_sync(path, content, keep_bom))
.await
.map_err(|e| format!("写入任务失败:{e}"))?
}
fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> {
let target = PathBuf::from(&path);
let dir = target
.parent()
@@ -76,7 +98,12 @@ pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<
.ok_or_else(|| "目标路径没有文件名".to_string())?
.to_string_lossy()
.into_owned();
let tmp = dir.join(format!(".{file_name}.tmp-{}", std::process::id()));
// 临时名带进程号 + 纳秒时间戳:自动保存与手动保存并发写同一文件时不共用临时文件
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = dir.join(format!(".{file_name}.tmp-{}-{nanos}", std::process::id()));
let mut data: Vec<u8> = Vec::with_capacity(content.len() + 3);
if keep_bom {
@@ -126,21 +153,34 @@ fn sanitize_file_name(name: &str) -> String {
let cleaned: String = base
.chars()
.map(|c| {
if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || c.is_control() {
'_'
} else {
c
}
})
.collect();
if cleaned.trim().is_empty() {
// Windows 会自动剥离结尾的点与空格,先在此对齐,避免登记名与落盘名不一致
let trimmed = cleaned.trim_end_matches(['.', ' ']);
let mut result = if trimmed.is_empty() {
"file".to_string()
} else {
cleaned
trimmed.to_string()
};
// Windows 保留设备名(CON/NUL 等)直接写会失败,加前缀避开
let stem_upper = result.split('.').next().unwrap_or("").to_ascii_uppercase();
let reserved = matches!(stem_upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem_upper.len() == 4
&& (stem_upper.starts_with("COM") || stem_upper.starts_with("LPT"))
&& stem_upper.as_bytes()[3].is_ascii_digit());
if reserved {
result = format!("_{result}");
}
result
}
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。落盘已改走 write_new_file,此函数保留备查。
#[allow(dead_code)]
fn dedupe_path(dir: &Path, file_name: &str) -> PathBuf {
let candidate = dir.join(file_name);
if !candidate.exists() {
@@ -175,6 +215,38 @@ fn relative_assets_path(doc_path: &str, target: &Path) -> String {
format!("{stem}.assets/{name}")
}
/// 以 create_new 方式逐个尝试候选名落盘:目标已存在就换下一个名字,
/// 绝不截断覆盖已有文件(消除 dedupe 先查后写的竞态,含兜底覆盖问题)。
fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, String> {
for candidate in std::iter::once(dir.join(file_name)).chain((1..1000).map(|i| {
let stem = Path::new(file_name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = Path::new(file_name)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
dir.join(format!("{stem}-{i}{ext}"))
})) {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(mut f) => {
use std::io::Write as _;
f.write_all(data)
.map_err(|e| format!("写入资源文件失败:{e}"))?;
return Ok(candidate);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("写入资源文件失败:{e}")),
}
}
Err("资源文件名冲突过多".to_string())
}
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
#[tauri::command]
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
@@ -185,25 +257,69 @@ pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String
.unwrap_or_else(|| "file".into()),
);
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::copy(&src, &target).map_err(|e| format!("复制资源文件失败:{e}"))?;
let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?;
let target = write_new_file(&dir, &name, &data)?;
Ok(relative_assets_path(&doc_path, &target))
}
/// 把字节内容(如粘贴的图片)写入文档资源目录,返回相对路径。
/// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。
/// 审计修复:原先用 Vec<u8> 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字;
/// 改传 base64 字符串,体积与解析开销都降一个量级。
#[tauri::command]
pub fn save_bytes_to_assets(
doc_path: String,
file_name: String,
bytes: Vec<u8>,
base64: String,
) -> Result<String, String> {
let name = sanitize_file_name(&file_name);
let bytes = base64_decode(&base64)?;
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::write(&target, &bytes).map_err(|e| format!("写入资源文件失败:{e}"))?;
let target = write_new_file(&dir, &name, &bytes)?;
Ok(relative_assets_path(&doc_path, &target))
}
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
fn value(b: u8) -> Result<u32, String> {
match b {
b'A'..=b'Z' => Ok((b - b'A') as u32),
b'a'..=b'z' => Ok((b - b'a' + 26) as u32),
b'0'..=b'9' => Ok((b - b'0' + 52) as u32),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err("base64 内容非法".to_string()),
}
}
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if clean.len() % 4 == 1 {
return Err("base64 长度非法".to_string());
}
let mut out = Vec::with_capacity(clean.len() / 4 * 3 + 3);
for chunk in clean.chunks(4) {
let mut n: u32 = 0;
let mut pad = 0;
for (i, &b) in chunk.iter().enumerate() {
if b == b'=' {
pad += 1;
n <<= 6;
} else {
n = (n << 6) | value(b)?;
}
let _ = i;
}
if chunk.len() < 4 {
n <<= 6 * (4 - chunk.len()) as u32;
}
out.push((n >> 16) as u8);
if pad < 2 && chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if pad < 1 && chunk.len() > 3 {
out.push(n as u8);
}
}
Ok(out)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirEntry {
@@ -289,17 +405,20 @@ pub fn stat_file(path: String) -> FileStat {
}
}
/// 新建空文本文件;目标已存在时报错(不覆盖)。
/// 新建空文本文件;目标已存在时报错(不覆盖)。用 create_new 保证检查与创建原子。
#[tauri::command]
pub fn create_text_file(path: String) -> Result<(), String> {
let p = Path::new(&path);
if p.exists() {
return Err("同名文件已存在".to_string());
}
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败:{e}"))?;
}
fs::write(p, "").map_err(|e| format!("创建文件失败:{e}"))
match fs::OpenOptions::new().write(true).create_new(true).open(p) {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
Err("同名文件已存在".to_string())
}
Err(e) => Err(format!("创建文件失败:{e}")),
}
}
#[tauri::command]
@@ -316,15 +435,19 @@ pub fn rename_path(from: String, to: String) -> Result<(), String> {
fs::rename(&from, &to).map_err(|e| format!("重命名失败:{e}"))
}
/// 删除到系统回收站(失败时退回永久删除仅限文件)。
/// 删除到系统回收站。返回实际去向:"trash" 或 "permanent"(回收站不可用时
/// 对文件退回永久删除)——审计修复:此前静默永久删除,用户以为可恢复;
/// 现在把去向回给前端明示。
#[tauri::command]
pub fn delete_to_trash(path: String) -> Result<(), String> {
pub fn delete_to_trash(path: String) -> Result<String, String> {
match trash::delete(&path) {
Ok(()) => Ok(()),
Ok(()) => Ok("trash".to_string()),
Err(e) => {
let p = Path::new(&path);
if p.is_file() {
fs::remove_file(p).map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}"))
fs::remove_file(p)
.map(|_| "permanent".to_string())
.map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}"))
} else {
Err(format!("删除失败:{e}"))
}
@@ -338,15 +461,14 @@ use crate::storage;
use crate::AppDb;
use tauri::State;
fn lock_db<'a>(
db: &'a State<'a, AppDb>,
) -> Result<std::sync::MutexGuard<'a, rusqlite::Connection>, String> {
db.0.lock().map_err(|_| "数据库锁损坏".to_string())
fn lock_db<'a>(db: &'a State<'a, AppDb>) -> std::sync::MutexGuard<'a, rusqlite::Connection> {
// 锁中毒(某命令持锁时 panic)后取回内部连接继续服务,而不是此后所有 DB 命令永久失败
db.0.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[tauri::command]
pub fn db_recent_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecentEntry>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_list(&conn, 30)
}
@@ -357,49 +479,49 @@ pub fn db_recent_add(
name: String,
opened_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_add(&conn, &path, &name, opened_at, 30)
}
#[tauri::command]
pub fn db_recent_remove(db: State<'_, AppDb>, path: String) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_remove(&conn, &path)
}
#[tauri::command]
pub fn db_recent_clear(db: State<'_, AppDb>) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recent_clear(&conn)
}
#[tauri::command]
pub fn db_workspace_list(db: State<'_, AppDb>) -> Result<Vec<String>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::workspace_list(&conn)
}
#[tauri::command]
pub fn db_workspace_add(db: State<'_, AppDb>, path: String, opened_at: i64) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::workspace_add(&conn, &path, opened_at)
}
#[tauri::command]
pub fn db_pref_get(db: State<'_, AppDb>, key: String) -> Result<Option<String>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::pref_get(&conn, &key)
}
#[tauri::command]
pub fn db_pref_set(db: State<'_, AppDb>, key: String, value: String) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::pref_set(&conn, &key, &value)
}
#[tauri::command]
pub fn db_recovery_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecoveryEntry>, String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recovery_list(&conn)
}
@@ -412,7 +534,7 @@ pub fn db_recovery_save(
content: String,
updated_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recovery_save(
&conn,
&doc_key,
@@ -425,7 +547,7 @@ pub fn db_recovery_save(
#[tauri::command]
pub fn db_recovery_delete(db: State<'_, AppDb>, doc_key: String) -> Result<(), String> {
let conn = lock_db(&db)?;
let conn = lock_db(&db);
storage::recovery_delete(&conn, &doc_key)
}
@@ -457,11 +579,20 @@ fn base64_encode(data: &[u8]) -> String {
out
}
/// 读任意文件并以 base64 返回(导出图片内嵌、DOCX 导入共用)。
/// 读任意文件并以 base64 返回(导出图片内嵌、DOCX 导入共用)。异步执行 + 32MB 上限。
#[tauri::command]
pub fn read_file_base64(path: String) -> Result<String, String> {
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
Ok(base64_encode(&bytes))
pub async fn read_file_base64(path: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > 32 * 1024 * 1024 {
return Err("文件过大(超过 32MB)".to_string());
}
}
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
Ok(base64_encode(&bytes))
})
.await
.map_err(|e| format!("读取任务失败:{e}"))?
}
// ————————————————— 阶段 6:工作区全文搜索(逻辑在 search.rs) —————————————————
@@ -469,10 +600,14 @@ pub fn read_file_base64(path: String) -> Result<String, String> {
use crate::search;
#[tauri::command]
pub fn workspace_search(
pub async fn workspace_search(
root: String,
query: String,
case_sensitive: bool,
) -> Vec<search::FileMatches> {
search::search_workspace(&root, &query, case_sensitive)
tauri::async_runtime::spawn_blocking(move || {
search::search_workspace(&root, &query, case_sensitive)
})
.await
.unwrap_or_default()
}

View File

@@ -1,9 +1,17 @@
//! 工作区全文搜索(阶段 6):递归扫描 Markdown/文本文件做逐行包含匹配。
//! 工作区全文搜索:递归扫描 Markdown/文本文件做逐行包含匹配。
//! 纯逻辑、不依赖 Tauri 类型,命令层只转发;带上限保护(文件大小/结果数/单文件命中数)。
//!
//! 审计修复(2026-11-13):
//! - 原实现用 `is_dir()` 跟随符号链接无条件递归,工作区里有指向父目录的
//! junction/符号链接时会无限递归直至栈溢出——现按规范化路径去重 + 深度上限;
//! - 增加「已扫描文件总数」上限,无命中时也不会把整棵树无界扫完;
//! - 解码与读文件路径对齐:UTF-8 失败时按 GBK 解码(原先 lossy 解码导致
//! GBK 文档的中文永远搜不到)。
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
@@ -24,6 +32,8 @@ const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const MAX_FILES_WITH_MATCHES: usize = 300;
const MAX_MATCHES_PER_FILE: usize = 20;
const SNIPPET_CHARS: usize = 160;
const MAX_DEPTH: usize = 24;
const MAX_SCANNED_FILES: usize = 20_000;
fn is_searchable(path: &Path) -> bool {
matches!(
@@ -32,8 +42,46 @@ fn is_searchable(path: &Path) -> bool {
)
}
fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &mut Vec<FileMatches>) {
if out.len() >= MAX_FILES_WITH_MATCHES {
/// 与 commands::decode_bytes 对齐的文本解码:UTF-8(含 BOM)优先,否则 GBK。
fn decode_text(bytes: &[u8]) -> String {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
if let Ok(s) = std::str::from_utf8(rest) {
return s.to_string();
}
let (text, _, _) = encoding_rs::GBK.decode(rest);
return text.into_owned();
}
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => {
let (text, _, _) = encoding_rs::GBK.decode(bytes);
text.into_owned()
}
}
}
struct ScanState {
visited_dirs: HashSet<PathBuf>,
scanned_files: usize,
}
fn scan_dir(
dir: &Path,
depth: usize,
needle: &str,
case_sensitive: bool,
state: &mut ScanState,
out: &mut Vec<FileMatches>,
) {
if out.len() >= MAX_FILES_WITH_MATCHES || depth > MAX_DEPTH || state.scanned_files >= MAX_SCANNED_FILES {
return;
}
// 按规范化路径去重:符号链接/junction 指回已访目录时直接跳过,杜绝无限递归
let canonical = match fs::canonicalize(dir) {
Ok(c) => c,
Err(_) => return,
};
if !state.visited_dirs.insert(canonical) {
return;
}
let entries = match fs::read_dir(dir) {
@@ -43,7 +91,7 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m
let mut paths: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
paths.sort();
for path in paths {
if out.len() >= MAX_FILES_WITH_MATCHES {
if out.len() >= MAX_FILES_WITH_MATCHES || state.scanned_files >= MAX_SCANNED_FILES {
return;
}
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
@@ -52,8 +100,9 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m
if name.starts_with('.') || name == "node_modules" {
continue;
}
scan_dir(&path, query, needle, case_sensitive, out);
scan_dir(&path, depth + 1, needle, case_sensitive, state, out);
} else if is_searchable(&path) {
state.scanned_files += 1;
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES {
continue;
@@ -63,7 +112,7 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m
Ok(b) => b,
Err(_) => continue,
};
let text = String::from_utf8_lossy(&bytes);
let text = decode_text(&bytes);
let mut matches = Vec::new();
for (idx, line) in text.lines().enumerate() {
let hit = if case_sensitive {
@@ -96,7 +145,8 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec<Fi
return Vec::new();
}
let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() };
let mut state = ScanState { visited_dirs: HashSet::new(), scanned_files: 0 };
let mut out = Vec::new();
scan_dir(Path::new(root), query, &needle, case_sensitive, &mut out);
scan_dir(Path::new(root), 0, &needle, case_sensitive, &mut state, &mut out);
out
}

View File

@@ -1,4 +1,4 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import TitleBar from './components/TitleBar'
@@ -25,7 +25,7 @@ import { editorCommands, insertMermaidBlock, insertMathBlock } from './editor/co
import {
isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile,
pickAndReadWebFile, downloadTextFile,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
} from './services/platform'
@@ -33,7 +33,7 @@ import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES } from './themes'
import { DOC_THEMES, docThemeById } from './themes'
import { markdownToExportHtml, htmlToMarkdown, docxToMarkdown, type ImageMode } from './services/exportHtml'
const WELCOME_MD = `# 欢迎使用 new-markdown
@@ -100,6 +100,12 @@ export default function App() {
const [exportOpen, setExportOpen] = useState(false)
/** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */
const baselinesRef = useRef(new Map<string, { mtimeMs: number; size: number }>())
/** 外部重载时抑制旧编辑器卸载回写的标签集合(见 reloadTabFromDisk) */
const suppressFinalWriteRef = useRef(new Set<string>())
/** UI 偏好是否已完成启动读库(之前不许把默认值写回库,防启动竞态覆盖) */
const uiLoadedRef = useRef(false)
/** 任一弹窗打开时为真:全局快捷键整体让行 */
const anyModalRef = useRef(false)
const conflictSeenRef = useRef(new Set<string>())
const recoveryWrittenRef = useRef(new Map<string, string>())
const lastTabsRef = useRef(new Map<string, { path: string | null; title: string }>())
@@ -125,6 +131,8 @@ export default function App() {
const findUiRef = useRef(findUi)
findUiRef.current = findUi
const pendingFindRef = useRef<string | null>(null)
const findTimerRef = useRef<number | undefined>(undefined)
const findPendingRef = useRef(false)
const activeFindApi = useCallback((): Pick<EditorPaneApi, 'findAll' | 'gotoMatch' | 'replaceCurrent' | 'replaceAll' | 'clearFind'> | SourceEditorApi | null => {
return sourceModeRef.current ? sourceApiRef.current : paneFindRef.current
@@ -154,6 +162,25 @@ export default function App() {
window.setTimeout(() => runFind(query, findUiRef.current.opts), 30)
}, [runFind])
const scheduleFind = useCallback((query: string, opts: FindOptions) => {
// 审计修复(性能):逐字输入查询词时每键都全量重建字符索引,大文档明显卡顿;防抖 150ms。
// Enter/上下翻页会先把挂起的查找立即执行(见 FindBar 的 onNext/onPrev)。
window.clearTimeout(findTimerRef.current)
findPendingRef.current = true
findTimerRef.current = window.setTimeout(() => {
findPendingRef.current = false
runFind(query, opts)
}, 150)
}, [runFind])
const flushPendingFind = useCallback((): boolean => {
if (!findPendingRef.current) return false
window.clearTimeout(findTimerRef.current)
findPendingRef.current = false
runFind(findUiRef.current.query, findUiRef.current.opts)
return true
}, [runFind])
const closeFind = useCallback(() => {
activeFindApi()?.clearFind()
setFindUi((f) => ({ ...f, open: false, count: 0, current: -1, error: null }))
@@ -161,9 +188,14 @@ export default function App() {
const tauriMode = isTauri()
const uiSaveTimerRef = useRef<number | undefined>(undefined)
useEffect(() => {
document.documentElement.dataset.theme = ui.theme
store.saveUiPref(JSON.stringify(ui))
if (!uiLoadedRef.current) return
window.clearTimeout(uiSaveTimerRef.current)
uiSaveTimerRef.current = window.setTimeout(() => {
store.saveUiPref(JSON.stringify(ui))
}, 400)
}, [ui])
// 工作区搜索点击结果:文件打开后自动打开查找条并定位
@@ -240,6 +272,8 @@ export default function App() {
}
}, [tauriMode, openPath])
const handleSidebarOpenFile = useCallback((path: string) => { void openPath(path) }, [openPath])
const openSearchResult = useCallback((path: string, query: string) => {
pendingFindRef.current = query
void openPath(path)
@@ -271,11 +305,17 @@ export default function App() {
}
}, [])
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder])
const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise<boolean> => {
// 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
const snapshot = target.content
const oldKey = recoveryKey(target)
try {
if (!tauriMode) {
downloadTextFile(`${target.title}.md`, target.content)
dispatch({ type: 'saved', id: target.id })
downloadTextFile(`${target.title}.md`, snapshot)
dispatch({ type: 'saved', id: target.id, savedContent: snapshot })
return true
}
let path = target.path
@@ -283,13 +323,16 @@ export default function App() {
path = await pickSaveFile(path ?? `${target.title}.md`)
if (!path) return false
}
await writeTextFile(path, toDiskContent(target.content, target.lineEnding), target.hadBom)
dispatch({ type: 'saved', id: target.id, path, title: baseName(path) })
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) })
const stat = await statFile(path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
const key = recoveryKey({ id: target.id, path })
recoveryWrittenRef.current.delete(key)
void store.deleteRecovery(key)
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
const newKey = recoveryKey({ id: target.id, path })
for (const key of new Set([oldKey, newKey])) {
recoveryWrittenRef.current.delete(key)
void store.deleteRecovery(key)
}
pushRecent(path)
return true
} catch (e) {
@@ -309,17 +352,30 @@ export default function App() {
}, [requestClose])
// ——— 阶段 4:存储初始化与启动恢复 ———
const reloadTabFromDisk = useCallback(async (target: DocTab) => {
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string) => {
if (!target.path) return
try {
const file = await readTextFile(target.path)
// 审计修复(检查—执行竞态):读取期间用户已输入或状态已变,则放弃这次重载,
// 交给下一轮轮询按冲突流程处理,绝不用磁盘内容整表覆盖新输入。
const current = stateRef.current.tabs.find((t) => t.id === target.id)
if (!current) return
if (expectedContent !== undefined && current.content !== expectedContent) return
if (isDirty(current)) return
const isActive = stateRef.current.activeId === target.id
// 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文
// 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。
if (isActive) {
suppressFinalWriteRef.current.add(target.id)
window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500)
}
dispatch({
type: 'loadContent', id: target.id, content: file.content,
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
})
const stat = await statFile(target.path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
if (stateRef.current.activeId === target.id) setEditorEpoch((n) => n + 1)
if (isActive) setEditorEpoch((n) => n + 1)
} catch (e) { setError(String(e)) }
}, [])
@@ -354,26 +410,31 @@ export default function App() {
useEffect(() => {
let cancelled = false
void (async () => {
const recentFromDb = await store.loadRecent()
if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path))
// 偏好先于最近列表读回并置位 uiLoadedRef:[ui] 保存 effect 在此之前不得写库,
// 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。
const uiJson = await store.loadUiPref()
if (!cancelled && uiJson) {
try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ }
}
uiLoadedRef.current = true
const recentFromDb = await store.loadRecent()
if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path))
// 启动恢复:先读磁盘现状再划分(与磁盘一致的记录静默清理)
const records = await store.listRecovery()
if (cancelled || records.length === 0) return
const disk = new Map<string, string | null>()
for (const record of records) {
if (!record.path || disk.has(record.path)) continue
try {
disk.set(record.path, (await readTextFile(record.path)).content)
} catch { disk.set(record.path, null) }
if (!cancelled && records.length > 0) {
const disk = new Map<string, string | null>()
for (const record of records) {
if (!record.path || disk.has(record.path)) continue
try {
disk.set(record.path, (await readTextFile(record.path)).content)
} catch { disk.set(record.path, null) }
}
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
if (plan.offer.length > 0) setRecoveryOffer(plan.offer)
}
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
if (!cancelled && plan.offer.length > 0) setRecoveryOffer(plan.offer)
// 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)
// 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)。
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
const startupFile = new URLSearchParams(window.location.search).get('file')
if (!cancelled && startupFile) void openPath(startupFile)
})()
@@ -391,6 +452,15 @@ export default function App() {
for (const [id, prev] of lastTabsRef.current) {
if (!current.has(id)) {
baselinesRef.current.delete(id)
for (const key of [...conflictSeenRef.current]) {
if (key.startsWith(`${id}:`)) conflictSeenRef.current.delete(key)
}
setDocOverrides((prevMap) => {
if (!(id in prevMap)) return prevMap
const next = { ...prevMap }
delete next[id]
return next
})
const key = recoveryKey({ id, path: prev.path })
if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key)
}
@@ -425,7 +495,8 @@ export default function App() {
if (!ui.autosave) return
const timer = window.setInterval(() => {
for (const t of stateRef.current.tabs) {
if (t.path && isDirty(t)) void saveTabRef.current(t, false)
// 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false)
}
}, Math.max(10, ui.autosaveSeconds) * 1000)
return () => window.clearInterval(timer)
@@ -449,7 +520,7 @@ export default function App() {
}
if (stat.mtimeMs === base.mtimeMs && stat.size === base.size) continue
if (!isDirty(t)) {
await reloadTabFromDisk(t)
await reloadTabFromDisk(t, t.content)
flash(`《${t.title}》已被外部修改,已自动重新加载`)
} else if (conflictRef.current !== t.id && !conflictSeenRef.current.has(`${t.id}:${stat.mtimeMs}`)) {
conflictSeenRef.current.add(`${t.id}:${stat.mtimeMs}`)
@@ -467,17 +538,19 @@ export default function App() {
let unlisten: (() => void) | undefined
void (async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
unlisten = await getCurrentWindow().onCloseRequested((event) => {
const win = getCurrentWindow()
unlisten = await win.onCloseRequested(async (event) => {
// 审计修复:原实现恢复写入是 fire-and-forget,只有未命名草稿时直接放行关窗,
// IPC 未落盘进程已退出、草稿照样丢。现先拦下关闭,等恢复记录全部写完再决定。
event.preventDefault()
const dirty = stateRef.current.tabs.filter(isDirty)
// 先把所有草稿刷进恢复记录,保证任何退出路径都不丢内容
for (const t of dirty) {
void store.saveRecovery({
docKey: recoveryKey(t), path: t.path, title: t.title, content: t.content, updatedAt: Date.now(),
})
}
await Promise.all(dirty.map((t) => store.saveRecovery({
docKey: recoveryKey(t), path: t.path, title: t.title, content: t.content, updatedAt: Date.now(),
})))
if (dirty.some((t) => t.path)) {
event.preventDefault()
setQuitOpen(true)
} else {
await win.destroy()
}
})
})()
@@ -547,7 +620,7 @@ export default function App() {
setConfirmSpec(null)
void (async () => {
try {
await deleteToTrash(entry.path)
const dest = await deleteToTrash(entry.path)
for (const t of stateRef.current.tabs) {
if (t.path && (t.path === entry.path || t.path.startsWith(`${entry.path}/`) || t.path.startsWith(`${entry.path}\\`))) {
dispatch({ type: 'close', id: t.id })
@@ -557,7 +630,9 @@ export default function App() {
}
store.removeRecent(entry.path)
setTreeRefresh((n) => n + 1)
flash(`已删除 ${entry.name}`)
flash(dest === 'permanent'
? `已永久删除 ${entry.name}(回收站不可用,未进回收站)`
: `已删除 ${entry.name}`)
} catch (e) { setError(String(e)) }
})()
},
@@ -593,6 +668,7 @@ export default function App() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫)
if (anyModalRef.current) return // 任一弹窗打开时让行,避免在弹窗背后触发编辑器动作
if (e.key === 'Escape' && findUiRef.current.open) { e.preventDefault(); closeFind(); return }
if (!e.ctrlKey && !e.metaKey && !e.altKey) return
const combo = comboFromEvent(e)
@@ -654,8 +730,7 @@ export default function App() {
if (isTauri() && current?.path) {
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg')
const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}`
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()))
return saveBytesToAssets(current.path, named, bytes)
return saveBytesToAssets(current.path, named, await fileToBase64(file))
}
return new Promise((resolve) => {
const reader = new FileReader()
@@ -697,8 +772,10 @@ export default function App() {
const dir = docPath.replace(/[\\/][^\\/]*$/, '')
return async (src: string): Promise<string | null> => {
try {
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('/') || src.startsWith('\\')
const raw = isAbsolute ? src : `${dir}/${src}`
let decoded = src
try { decoded = decodeURIComponent(src) } catch { /* 非法转义时用原文 */ }
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/') || decoded.startsWith('\\')
const raw = isAbsolute ? decoded : `${dir}/${decoded}`
const drive = raw.match(/^[a-zA-Z]:/)?.[0] ?? ''
const segments: string[] = []
for (const part of raw.slice(drive.length).replace(/\\/g, '/').split('/')) {
@@ -706,7 +783,8 @@ export default function App() {
else if (part !== '.' && part !== '') segments.push(part)
}
const joined = drive + '/' + segments.join('/')
const ext = (src.split('.').pop() ?? '').toLowerCase()
const cleanSrc = src.split(/[?#]/)[0]
const ext = (cleanSrc.split('.').pop() ?? '').toLowerCase()
const mime = ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'application/octet-stream'
const base64 = await readFileBase64(joined)
@@ -764,9 +842,21 @@ export default function App() {
const printDoc = useCallback(() => {
if (!tab) return
if (sourceMode) toggleSourceMode()
flash('在打印对话框中可选择「另存为 PDF」导出 PDF')
window.setTimeout(() => window.print(), 60)
if (!sourceMode) {
window.setTimeout(() => window.print(), 60)
return
}
// 审计修复:原先固定等 60ms 就打印,源码切渲染是异步重建,大文档必然赛跑。
// 改为等渲染编辑器就绪(最多 3 秒兜底)后再打印。
toggleSourceMode()
const started = Date.now()
const timer = window.setInterval(() => {
if (editorRef.current || Date.now() - started > 3000) {
window.clearInterval(timer)
window.requestAnimationFrame(() => window.print())
}
}, 50)
}, [tab, sourceMode, toggleSourceMode, flash])
const printRef = useRef(printDoc)
printRef.current = printDoc
@@ -792,7 +882,23 @@ export default function App() {
} catch (e) { setError(String(e)) }
}, [tauriMode, flash])
const outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab])
anyModalRef.current = Boolean(
modal || appearanceOpen || exportOpen || pendingClose || conflict
|| promptSpec || confirmSpec || quitOpen || recoveryOffer.length > 0,
)
// 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算);
// 大纲再经 useDeferredValue 让高频输入时延后提取,不堵击键路径。
const deferredContent = useDeferredValue(tab?.content ?? '')
const outline = useMemo(
() => (tab ? extractOutline(deferredContent) : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tab?.id, deferredContent],
)
const statusCounts = useMemo(() => {
if (!tab) return { words: 0, chars: 0, lines: 0 }
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
}, [tab?.id, tab?.content])
const menus: MenuDef[] = useMemo(() => [
{
@@ -927,8 +1033,8 @@ export default function App() {
onModeChange={setSidebarMode}
workspaceRoot={workspaceRoot}
activePath={tab?.path ?? null}
onOpenFile={(path) => void openPath(path)}
onOpenFolder={() => void openFolder()}
onOpenFile={handleSidebarOpenFile}
onOpenFolder={handleSidebarOpenFolder}
onCreateFile={tauriMode && workspaceRoot ? createFileInWorkspace : undefined}
onRenameEntry={tauriMode ? renameEntry : undefined}
onDeleteEntry={tauriMode ? deleteEntry : undefined}
@@ -953,11 +1059,13 @@ export default function App() {
) : sourceMode ? (
<Suspense fallback={<div className="empty-state">正在加载源码编辑器…</div>}>
<SourceEditor
key={`source:${tab.id}:${ui.theme}`}
key={`source:${tab.id}`}
tabId={tab.id}
initialText={tab.content}
initialCursorOffset={sourceCursorOffset}
dark={ui.theme === 'dark'}
dark={docThemeById(effectiveDocTheme).paperTone
? docThemeById(effectiveDocTheme).paperTone === 'dark'
: ui.theme === 'dark'}
onChange={(text) => dispatch({ type: 'updateContent', id: tab.id, content: text })}
onReady={(api) => { sourceApiRef.current = api }}
/>
@@ -968,10 +1076,15 @@ export default function App() {
tabId={tab.id}
initialMarkdown={tab.content}
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
onFinalMarkdown={(id, md) => {
if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃
dispatch({ type: 'updateContent', id, content: md })
}}
onCursor={setCursor}
focusMode={ui.focusMode}
typewriter={ui.typewriter}
onImageFile={handleImageFile}
onImageError={(message) => setError(`图片插入失败:${message}`)}
onReady={(api) => {
paneFindRef.current = api.editor ? api : null
editorRef.current = api.editor
@@ -993,12 +1106,12 @@ export default function App() {
count={findUi.count}
current={findUi.current}
error={findUi.error}
onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); runFind(value, findUiRef.current.opts) }}
onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); scheduleFind(value, findUiRef.current.opts) }}
onReplacementChange={(value) => setFindUi((f) => ({ ...f, replacement: value }))}
onToggleReplace={() => setFindUi((f) => ({ ...f, replace: !f.replace }))}
onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); runFind(findUiRef.current.query, opts) }}
onNext={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onPrev={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); scheduleFind(findUiRef.current.query, opts) }}
onNext={() => { if (flushPendingFind()) return; const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onPrev={() => { if (flushPendingFind()) return; const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onReplace={() => {
const api = activeFindApi()
if (!api || !api.replaceCurrent(findUiRef.current.replacement)) return
@@ -1019,9 +1132,9 @@ export default function App() {
</section>
</div>
<StatusBar
words={tab ? countWords(tab.content) : 0}
chars={tab ? countChars(tab.content) : 0}
lines={tab ? tab.content.split('\n').length : 0}
words={statusCounts.words}
chars={statusCounts.chars}
lines={statusCounts.lines}
cursor={cursor}
encoding={tab?.encoding ?? 'UTF-8'}
lineEnding={tab?.lineEnding ?? 'LF'}

View File

@@ -24,9 +24,12 @@ interface Props {
tabId: string
initialMarkdown: string
onMarkdown: (tabId: string, markdown: string) => void
/** 卸载前的最终回写(与实时 onMarkdown 分开,供外部重载时拦截旧内容回写) */
onFinalMarkdown?: (tabId: string, markdown: string) => void
onCursor: (cursor: CursorInfo) => void
onReady: (api: EditorPaneApi) => void
onImageFile?: (file: File) => Promise<string>
onImageError?: (message: string) => void
focusMode: boolean
typewriter: boolean
}
@@ -44,7 +47,7 @@ const EMPTY_API: EditorPaneApi = {
}
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile, focusMode, typewriter }: Props) {
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) {
const wrapRef = useRef<HTMLDivElement>(null)
const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({
matches: [], current: -1, query: '', opts: null,
@@ -65,8 +68,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
}, [])
const hostRef = useRef<HTMLDivElement>(null)
const initialRef = useRef(initialMarkdown)
const callbacksRef = useRef({ onMarkdown, onCursor, onReady, onImageFile })
callbacksRef.current = { onMarkdown, onCursor, onReady, onImageFile }
const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError })
callbacksRef.current = { onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError }
useEffect(() => {
if (!hostRef.current) return
@@ -90,13 +93,23 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
})
}
const editor = createMarkdownEditor(hostRef.current, initialRef.current, {
onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md),
onMarkdown: (md) => {
// 文档变化后已算出的查找命中立即过期:下一次查找操作会先按新内容重算,
// 杜绝替换打在偏移后的错误位置上(审计高危)。
if (findRef.current.matches.length > 0) {
findRef.current = { matches: [], current: -1, query: findRef.current.query, opts: findRef.current.opts }
}
callbacksRef.current.onMarkdown(tabId, md)
},
onCursor: (c) => {
callbacksRef.current.onCursor(c)
if (modesRef.current.typewriter) centerCursorLine()
},
onTableContext: (inTable, el) => { tableElRef.current = el; if (inTable) updateTableBar(); else setTableBar(null) },
}, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) })
}, {
onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)),
onImageError: (message) => callbacksRef.current.onImageError?.(message),
})
editorObjRef.current = editor
const jumpToHeading = (text: string) => {
const els = hostRef.current?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? []
@@ -148,7 +161,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
}
}
const gotoMatch = (index: number): number => {
const found = findRef.current
let found = findRef.current
if (found.matches.length === 0 && found.query && found.opts) {
findAll(found.query, found.opts)
found = findRef.current
}
if (found.matches.length === 0) return -1
const wrapped = ((index % found.matches.length) + found.matches.length) % found.matches.length
found.current = wrapped
@@ -161,7 +178,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
return wrapped
}
const replaceCurrent = (replacement: string): boolean => {
const found = findRef.current
let found = findRef.current
if (found.matches.length === 0 && found.query && found.opts) {
findAll(found.query, found.opts)
found = findRef.current
}
if (found.current < 0 || !found.opts) return false
const m = found.matches[found.current]
if (!m) return false
@@ -170,13 +191,16 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
view.dispatch(view.state.tr.insertText(replacement, m.from, m.to))
view.focus()
})
const previous = found.current
const count = findAll(found.query, found.opts)
if (count > 0) gotoMatch(Math.min(previous, count - 1))
// 命中刷新与定位统一由 App 层在替换后做(原先两层各算一遍、序号还对不齐)
findRef.current = { matches: [], current: -1, query: found.query, opts: found.opts }
return true
}
const replaceAll = (replacement: string): number => {
const found = findRef.current
let found = findRef.current
if (found.matches.length === 0 && found.query && found.opts) {
findAll(found.query, found.opts)
found = findRef.current
}
if (found.matches.length === 0) return 0
const total = found.matches.length
const reversed = [...found.matches].reverse()
@@ -210,7 +234,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
editorObjRef.current = null
setTableBar(null)
try {
callbacksRef.current.onMarkdown(tabId, getMarkdown(editor))
const finalMarkdown = getMarkdown(editor)
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
} catch { /* 编辑器未就绪时跳过回写 */ }
callbacksRef.current.onReady(EMPTY_API)
editor.destroy().catch(() => undefined)

View File

@@ -46,6 +46,7 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
onClose: () => void
}) {
const [recordingId, setRecordingId] = useState<string | null>(null)
const [recordHint, setRecordHint] = useState<string | null>(null)
const [conflict, setConflict] = useState<{ id: string; combo: string; ownerLabel: string; ownerId: string } | null>(null)
const combos = effectiveCombos(overrides)
@@ -57,6 +58,12 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
if (e.key === 'Escape') { setRecordingId(null); return }
const combo = comboFromEvent(e)
if (!combo) return
if (!combo.includes('ctrl') && !combo.includes('alt') && !combo.includes('meta')) {
// 全局分发只处理带修饰键的组合,纯按键绑定永不触发,录入时直接拦下说明
setRecordHint('快捷键需要带 Ctrl 或 Alt 修饰键,纯按键不会被应用触发。')
return
}
setRecordHint(null)
const owner = comboOwner(combo, overrides, recordingId)
setRecordingId(null)
if (owner) {
@@ -93,6 +100,7 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
)
})}
</div>
{recordHint && <p className="modal-note">{recordHint}</p>}
{conflict && (
<p className="modal-note conflict-note">
<b>{prettyCombo(conflict.combo)}</b> 已被「{conflict.ownerLabel}」占用。

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
import type { OutlineItem } from '../utils/text'
import { baseName } from '../utils/text'
@@ -20,7 +20,7 @@ interface Props {
tauriMode: boolean
}
export default function Sidebar(p: Props) {
function Sidebar(p: Props) {
return (
<aside className="sidebar">
<div className="sidebar-tabs">
@@ -174,6 +174,7 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
onOpen?: (path: string, query: string) => void
}) {
const [query, setQuery] = useState('')
const [searchedQuery, setSearchedQuery] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [results, setResults] = useState<WorkspaceSearchFile[] | null>(null)
const [searching, setSearching] = useState(false)
@@ -184,7 +185,9 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
setSearching(true)
setError(null)
try {
setResults(await workspaceSearch(root, query.trim(), caseSensitive))
const word = query.trim()
setResults(await workspaceSearch(root, word, caseSensitive))
setSearchedQuery(word)
} catch (e) {
setError(String(e))
} finally {
@@ -192,6 +195,14 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
}
}
// 换工作区后旧结果作废,避免点到上一个工作区的文件
const [lastRoot, setLastRoot] = useState(root)
if (lastRoot !== root) {
setLastRoot(root)
setResults(null)
setSearchedQuery('')
}
if (!tauriMode) return <div className="sidebar-empty">Web 降级模式不支持工作区搜索。</div>
if (!root) return <div className="sidebar-empty">先打开一个文件夹,再搜索其中的文档。</div>
const total = results?.reduce((acc, f) => acc + f.matches.length, 0) ?? 0
@@ -225,7 +236,7 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
<button
key={i}
className="ws-search-hit"
onClick={() => onOpen?.(file.path, query.trim())}
onClick={() => onOpen?.(file.path, searchedQuery)}
title={`${file.path}:${m.line}`}
>
<span className="ws-search-line">{m.line}</span>
@@ -238,3 +249,7 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
</div>
)
}
// 审计修复(性能):组件 memo 化——正文击键时 App 整树重渲染,侧边栏(文件树/大纲)不必跟随
// (调用方需传稳定回调与已 memo 的 outline,见 App)。
export default memo(Sidebar)

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import { EditorState, Compartment } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
@@ -27,7 +27,8 @@ function findInText(text: string, query: string, opts: FindOptions): { from: num
if (opts.regex) {
let re: RegExp
try {
re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi')
const pattern = opts.wholeWord ? `\\b(?:${query})\\b` : query
re = new RegExp(pattern, opts.caseSensitive ? 'g' : 'gi')
} catch (e) {
throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`)
}
@@ -39,8 +40,16 @@ function findInText(text: string, query: string, opts: FindOptions): { from: num
}
return hits
}
const haystack = opts.caseSensitive ? text : text.toLowerCase()
const needle = opts.caseSensitive ? query : query.toLowerCase()
const lowerKeepLen = (value: string) => {
let out = ''
for (const ch of value) {
const lower = ch.toLowerCase()
out += lower.length === ch.length ? lower : ch
}
return out
}
const haystack = opts.caseSensitive ? text : lowerKeepLen(text)
const needle = opts.caseSensitive ? query : lowerKeepLen(query)
let from = 0
while (from <= haystack.length - needle.length) {
const hit = haystack.indexOf(needle, from)
@@ -67,6 +76,9 @@ interface Props {
/** 源码模式编辑器(CodeMirror 6):行号、Markdown 高亮、历史栈与渲染模式相互独立,进入/退出靠锚点同步光标。 */
export default function SourceEditor({ tabId, initialText, initialCursorOffset, dark, onChange, onReady }: Props) {
const hostRef = useRef<HTMLDivElement>(null)
const themeCompartmentRef = useRef(new Compartment())
const darkRef = useRef(dark)
darkRef.current = dark
const viewRef = useRef<EditorView | null>(null)
const initialRef = useRef({ initialText, initialCursorOffset })
const callbacksRef = useRef({ onChange, onReady })
@@ -74,6 +86,10 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
useEffect(() => {
if (!hostRef.current) return
let findMatches: { from: number; to: number }[] = []
let findCurrent = -1
let findQuery = ''
let findOpts: FindOptions | null = null
const { initialText: doc, initialCursorOffset: cursor } = initialRef.current
const view = new EditorView({
parent: hostRef.current,
@@ -92,35 +108,36 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
markdown({ codeLanguages: languages }),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacksRef.current.onChange(update.state.doc.toString())
if (update.docChanged) {
findMatches = []
findCurrent = -1
callbacksRef.current.onChange(update.state.doc.toString())
}
}),
themeCompartmentRef.current.of(darkRef.current ? [oneDark] : []),
EditorView.theme({
'&': { height: '100%', fontSize: '14px', backgroundColor: 'transparent' },
'&': { height: '100%', fontSize: 'calc(14px * var(--doc-zoom, 1))', backgroundColor: 'transparent' },
'.cm-content': { fontFamily: '"Cascadia Code", Consolas, monospace', padding: '24px 0', lineHeight: '1.7' },
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
}),
...(dark ? [oneDark] : []),
],
}),
})
viewRef.current = view
view.focus()
let matches: { from: number; to: number }[] = []
let current = -1
let lastQuery = ''
let lastOpts: FindOptions | null = null
const findAll = (query: string, opts: FindOptions): number => {
matches = findInText(view.state.doc.toString(), query, opts)
current = matches.length ? 0 : -1
lastQuery = query
lastOpts = opts
return matches.length
findMatches = findInText(view.state.doc.toString(), query, opts)
findCurrent = findMatches.length ? 0 : -1
findQuery = query
findOpts = opts
return findMatches.length
}
const gotoMatch = (index: number): number => {
if (matches.length === 0) return -1
const wrapped = ((index % matches.length) + matches.length) % matches.length
current = wrapped
const m = matches[wrapped]
if (findMatches.length === 0 && findQuery && findOpts) findAll(findQuery, findOpts)
if (findMatches.length === 0) return -1
const wrapped = ((index % findMatches.length) + findMatches.length) % findMatches.length
findCurrent = wrapped
const m = findMatches[wrapped]
view.dispatch({
selection: { anchor: m.from, head: m.to },
effects: EditorView.scrollIntoView(m.from, { y: 'center' }),
@@ -140,27 +157,29 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
findAll,
gotoMatch,
replaceCurrent: (replacement) => {
if (current < 0 || !lastOpts) return false
const m = matches[current]
if (findMatches.length === 0 && findQuery && findOpts) findAll(findQuery, findOpts)
if (findCurrent < 0 || !findOpts) return false
const m = findMatches[findCurrent]
if (!m) return false
view.dispatch({ changes: { from: m.from, to: m.to, insert: replacement } })
view.focus()
const previous = current
const count = findAll(lastQuery, lastOpts)
if (count > 0) gotoMatch(Math.min(previous, count - 1))
// 命中刷新与定位统一由 App 层在替换后做(与渲染模式契约一致)
findMatches = []
findCurrent = -1
return true
},
replaceAll: (replacement) => {
if (matches.length === 0) return 0
const total = matches.length
const changes = [...matches].reverse().map((m) => ({ from: m.from, to: m.to, insert: replacement }))
if (findMatches.length === 0 && findQuery && findOpts) findAll(findQuery, findOpts)
if (findMatches.length === 0) return 0
const total = findMatches.length
const changes = [...findMatches].reverse().map((m) => ({ from: m.from, to: m.to, insert: replacement }))
view.dispatch({ changes })
view.focus()
matches = []
current = -1
findMatches = []
findCurrent = -1
return total
},
clearFind: () => { matches = []; current = -1 },
clearFind: () => { findMatches = []; findCurrent = -1 },
})
return () => {
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入
@@ -172,5 +191,12 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId])
// UI/文档主题变化时只重配主题扩展,不重挂组件(原先靠 key 重挂,光标与撤销史全丢)
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: themeCompartmentRef.current.reconfigure(dark ? [oneDark] : []) })
}, [dark])
return <div className="source-cm" ref={hostRef} />
}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { DOC_THEMES } from '../themes'
/** 外观与设置(阶段 5):文档主题、当前文档临时主题、自定义 CSS、自动保存间隔。 */
@@ -14,9 +15,15 @@ export function AppearanceModal({ docTheme, override, customCss, autosave, autos
onAutosave: (patch: { autosave?: boolean; autosaveSeconds?: number }) => void
onClose: () => void
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-backdrop">
<div className="modal" style={{ width: 560 }}>
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 560 }} onClick={(e) => e.stopPropagation()}>
<h3>外观与设置</h3>
<div className="setting-label">文档主题(作用于编辑区排版)</div>
<div className="theme-grid">
@@ -92,9 +99,15 @@ export function ExportModal({ title, onExportHtml, onCopyHtml, onPrint, onClose
onPrint: () => void
onClose: () => void
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-backdrop">
<div className="modal">
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>导出《{title}》</h3>
<div className="modal-actions column left">
<button onClick={() => onExportHtml('relative')}>导出 HTML(图片保持相对路径)</button>

View File

@@ -94,7 +94,15 @@ export function setAlignColumn(editor: Editor, align: 'left' | 'center' | 'right
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, headerCellPos + 2)))
ctx.get(commandsCtx).call(setAlignCommand.key, align)
const now = view.state
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, Math.min(original.from, now.doc.content.size))))
// 审计修复:恢复完整选区(原先只恢复起点,多格/文本选区被塌陷成一个光标)
const size = now.doc.content.size
const from = Math.min(original.from, size)
const to = Math.min(original.to, size)
try {
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, from, Math.max(from, to))))
} catch {
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, from)))
}
})
}

View File

@@ -45,5 +45,7 @@ export function findBlockIndex(blockTexts: string[], anchor: CursorAnchor): numb
if (!anchor.blockText) return -1
const exact = blockTexts.findIndex((t) => t === anchor.blockText)
if (exact >= 0) return exact
return blockTexts.findIndex((t) => t.includes(anchor.blockText) || anchor.blockText.includes(t))
// 兜底包含匹配必须跳过空块:空字符串是任何字符串的子串,
// 文档前部只要有空段落就会把光标劫持过去(审计修复)。
return blockTexts.findIndex((t) => t !== '' && (t.includes(anchor.blockText) || anchor.blockText.includes(t)))
}

View File

@@ -27,6 +27,7 @@ export interface EditorCallbacks {
export interface EditorOptions {
/** 粘贴/拖入图片文件的落盘处理:返回应写入文档的图片地址(相对路径或 data URL)。缺省用会话级对象 URL。 */
onImageFile?: (file: File) => Promise<string>
onImageError?: (message: string) => void
}
export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, callbacks: EditorCallbacks = {}, options: EditorOptions = {}) {
@@ -64,9 +65,14 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
const nodes: unknown[] = []
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue
const src = options.onImageFile ? await options.onImageFile(file) : URL.createObjectURL(file)
const imageNode = schema.nodes.image?.createAndFill({ src, alt: file.name })
if (imageNode) nodes.push(imageNode)
try {
const src = options.onImageFile ? await options.onImageFile(file) : URL.createObjectURL(file)
const imageNode = schema.nodes.image?.createAndFill({ src, alt: file.name })
if (imageNode) nodes.push(imageNode)
} catch (e) {
// 落盘失败时跳过这张图并上报,而不是让整个粘贴/拖入无声失败或残留占位
options.onImageError?.(e instanceof Error ? e.message : String(e))
}
}
return nodes
}) as UploadOptions['uploader'],
@@ -90,6 +96,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
}
interface PmSelection {
from: number
$from: {
parentOffset: number
depth: number
@@ -104,13 +111,28 @@ interface PmSelection {
export function cursorFromSelection(selection: unknown): CursorInfo {
const sel = selection as PmSelection
const $from = sel.$from
// 审计修复:depth=0 时(全选 Ctrl+A、点中公式块/图片等顶层原子节点的 NodeSelection)
// $from.before(0) 会抛 RangeError。此时以选区起点为锚:行 = 起点之前的文本块数 + 1,列 = 1。
if ($from.depth === 0) {
let before = 0
let passed = false
$from.doc.descendants((node, pos) => {
if (passed) return false
if (pos >= sel.from) { passed = true; return false }
if (node.isTextblock) before += 1
return true
})
return { line: before + 1, col: 1 }
}
const parentPos = $from.before($from.depth)
let line = 0
let current = 1
let found = false
$from.doc.descendants((node, pos) => {
if (found) return false // 找到目标块后不再往后数(原先每次选区变化都数完整篇)
if (node.isTextblock) {
line += 1
if (pos === parentPos) current = line
if (pos === parentPos) { current = line; found = true; return false }
}
return true
})

View File

@@ -12,7 +12,7 @@ interface ListItemNode {
}
const listItemView: NodeViewConstructor = (node, view, getPos) => {
const item = node as unknown as ListItemNode
let item = node as unknown as ListItemNode
const dom = document.createElement('li')
dom.dataset.itemType = item.attrs.checked != null ? 'task' : 'list'
if (item.attrs.label) dom.dataset.label = item.attrs.label
@@ -54,6 +54,11 @@ const listItemView: NodeViewConstructor = (node, view, getPos) => {
const wasTask = item.attrs.checked != null
const isTask = next.attrs.checked != null
if (wasTask !== isTask) return false // 任务性变化时交回 PM 重建视图
// 审计修复:闭包 item 此前从不更新,wasTask 永远按创建时判断、label 永不刷新
item = next
if (next.attrs.label) dom.dataset.label = next.attrs.label
else delete dom.dataset.label
if (next.attrs.listType) dom.dataset.listType = next.attrs.listType
if (checkbox && next.attrs.checked != null) {
checkbox.checked = Boolean(next.attrs.checked)
dom.dataset.checked = String(next.attrs.checked)
@@ -104,6 +109,7 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
if (next.type.name !== 'code_block') return false
if ((next.attrs.language === 'mermaid') !== isMermaid()) return false
current = next
code.className = current.attrs.language ? `language-${current.attrs.language}` : ''
return true
},
}
@@ -111,6 +117,8 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
let mode: 'preview' | 'source' = 'preview'
let renderTimer: ReturnType<typeof setTimeout> | null = null
let renderSeq = 0
let destroyed = false
mermaidSeq += 1
const renderId = `new-markdown-mermaid-${mermaidSeq}`
@@ -144,7 +152,9 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
}
const render = async () => {
if (mode !== 'preview') return
if (mode !== 'preview' || destroyed) return
// 代次守卫:快速连续编辑时先启动的旧渲染后完成,不得用旧 SVG 覆盖新预览
const mySeq = ++renderSeq
const source = current.textContent
try {
const mermaid = (await import('mermaid')).default
@@ -153,10 +163,12 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
theme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
})
const { svg } = await mermaid.render(renderId, source)
const { svg } = await mermaid.render(`${renderId}-${mySeq}`, source)
if (destroyed || mySeq !== renderSeq) return
preview.innerHTML = svg
preview.classList.remove('mermaid-error')
} catch (e) {
if (destroyed || mySeq !== renderSeq) return
preview.textContent = `图表渲染失败:${e instanceof Error ? e.message : String(e)}(可切换到源码修改)`
preview.classList.add('mermaid-error')
}
@@ -190,7 +202,7 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
},
stopEvent: (event) => event.target === toggle,
ignoreMutation: (mutation) => preview.contains(mutation.target as Node) || head.contains(mutation.target as Node),
destroy: () => { if (renderTimer) clearTimeout(renderTimer) },
destroy: () => { destroyed = true; if (renderTimer) clearTimeout(renderTimer) },
}
}

View File

@@ -8,6 +8,25 @@ export function insertPlainText(editor: Editor, text: string): boolean {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to } = view.state.selection
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const paragraph = view.state.schema.nodes.paragraph
// 审计修复:多行文本原先被 insertText 塞进单个文本节点,渲染折叠成空格、
// 序列化却保留字面换行,往返不保真。选区在普通段落/标题里时按段落节点插入。
const parentType = view.state.selection.$from.parent.type.name
if (lines.length > 1 && paragraph && (parentType === 'paragraph' || parentType === 'heading')) {
const nodes = lines.map((line) => paragraph.create(null, line ? view.state.schema.text(line) : undefined))
view.dispatch(view.state.tr.replaceSelectionWith(nodes[0]).scrollIntoView())
// replaceSelectionWith 只放第一个段落,其余段落用 insert 续在后面
let pos = view.state.selection.to
const tr = view.state.tr
for (const node of nodes.slice(1)) {
tr.insert(pos, node)
pos += node.nodeSize
}
view.dispatch(tr.scrollIntoView())
view.focus()
return
}
view.dispatch(view.state.tr.insertText(text, from, to).scrollIntoView())
view.focus()
})

View File

@@ -147,22 +147,54 @@ interface MinimalView {
dispatch: (tr: unknown) => void
}
/** 代码块内缩进/反缩进。返回 true 表示已处理。导出供测试。 */
interface MinimalSelection {
$from: MinimalView['state']['selection']['$from']
$to?: { parentOffset: number }
}
/** 代码块内缩进/反缩进。返回 true 表示已处理。导出供测试。
* 审计修复:原先只处理选区起点一行;现对选区覆盖的每一行整体处理(单事务)。 */
export function applyCodeBlockIndent(view: MinimalView, outdent: boolean): boolean {
const { $from } = view.state.selection
const selection = view.state.selection as MinimalView['state']['selection'] & MinimalSelection
const { $from } = selection
if ($from.parent.type.name !== 'code_block') return false
const text = $from.parent.textContent
const offset = $from.parentOffset
const blockStart = $from.start($from.depth)
if (!outdent) {
view.dispatch(view.state.tr.insertText(' ', blockStart + offset))
return true
const fromOffset = $from.parentOffset
const toOffset = selection.$to?.parentOffset ?? fromOffset
// 计算每行在块文本中的起始偏移
const lineStarts: number[] = [0]
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') lineStarts.push(i + 1)
}
const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1
const leading = text.slice(lineStart, lineStart + 2)
const remove = leading.startsWith(' ') ? 2 : leading.startsWith(' ') ? 1 : 0
if (remove === 0) return true // 已在行首无缩进:吞掉 Tab,避免焦点跳走
view.dispatch(view.state.tr.delete(blockStart + lineStart, blockStart + lineStart + remove))
const lineOf = (offset: number) => {
let line = 0
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] <= offset) line = i
else break
}
return line
}
const firstLine = lineOf(fromOffset)
let lastLine = lineOf(toOffset)
// 选区恰好止于某行行首时不处理那一行(与主流编辑器一致)
if (toOffset > fromOffset && lineStarts[lastLine] === toOffset) lastLine -= 1
const tr = view.state.tr as unknown as {
insertText: (text: string, from: number) => unknown
delete: (from: number, to: number) => unknown
}
// 倒序处理各行,靠前行的偏移不受后面修改影响
for (let line = lastLine; line >= firstLine; line--) {
const lineStart = lineStarts[line]
if (!outdent) {
tr.insertText(' ', blockStart + lineStart)
} else {
const leading = text.slice(lineStart, lineStart + 2)
const remove = leading.startsWith(' ') ? 2 : leading.startsWith(' ') ? 1 : 0
if (remove > 0) tr.delete(blockStart + lineStart, blockStart + lineStart + remove)
}
}
view.dispatch(tr)
return true
}
@@ -178,22 +210,49 @@ export function isFocusModeEnabled() {
const focusKey = new PluginKey('new-markdown-focus')
interface FocusState {
set: DecorationSet
activeFrom: number
activeTo: number
}
const EMPTY_FOCUS: FocusState = { set: DecorationSet.empty, activeFrom: -1, activeTo: -1 }
export function createFocusDimPlugin() {
return $prose(() => new Plugin({
key: focusKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, old, _oldState, newState) => {
if (!focusModeEnabled) return DecorationSet.empty
if (!tr.selectionSet && !tr.docChanged && !tr.getMeta(focusKey)) return old
init: (): FocusState => EMPTY_FOCUS,
apply: (tr, val, _oldState, newState): FocusState => {
if (!focusModeEnabled) return EMPTY_FOCUS
const { doc, selection } = newState
const anchor = selection.$from
// 找选区所在的顶层块范围
let activeFrom = -1
let activeTo = -1
if (anchor.depth >= 1) {
activeFrom = anchor.before(1)
activeTo = anchor.after(1)
} else if (selection.from !== selection.to) {
// 顶层原子节点被选中(NodeSelection):以选区范围为活动块,
// 原先这种情况 activeFrom 恒 -1、整篇(含选中块)一起变淡。
activeFrom = selection.from
activeTo = selection.to
}
const force = Boolean(tr.getMeta(focusKey))
let set = val.set
let prevFrom = val.activeFrom
let prevTo = val.activeTo
if (tr.docChanged) {
set = set.map(tr.mapping, doc)
if (prevFrom >= 0) {
prevFrom = tr.mapping.map(prevFrom)
prevTo = tr.mapping.map(prevTo, -1)
}
}
// 审计修复(性能):活动块未变时复用映射后的旧装饰集——原先每次击键
// 都为全文顶层块全量重建 DecorationSet。跨块移动/强制刷新时才重建。
if (!force && prevFrom === activeFrom && prevTo === activeTo) {
return prevFrom === val.activeFrom && set === val.set ? val : { set, activeFrom, activeTo }
}
const decorations: Decoration[] = []
doc.forEach((node, offset) => {
@@ -203,11 +262,11 @@ export function createFocusDimPlugin() {
decorations.push(Decoration.node(from, to, { class: 'focus-dim' }))
}
})
return DecorationSet.create(doc, decorations)
return { set: DecorationSet.create(doc, decorations), activeFrom, activeTo }
},
},
props: {
decorations: (state) => focusKey.getState(state) as DecorationSet,
decorations: (state) => (focusKey.getState(state) as FocusState).set,
},
}))
}
@@ -275,14 +334,14 @@ export function createTableTabPlugin() {
event.preventDefault()
const commands = ctx.get(commandsCtx) as unknown as { call: (k: unknown, p?: unknown) => boolean }
if (event.shiftKey) {
commands.call(goToPrevTableCellCommand.key)
return true
return commands.call(goToPrevTableCellCommand.key)
}
if (isLastTableCell(view.state as unknown as { selection: { $from: AncestorLike }; doc: PmDocLike })) {
commands.call(addRowAfterCommand.key)
// 加行失败时不吞键(原先无条件 return true,Tab 被吃掉且光标不动、用户无从得知)
const added = commands.call(addRowAfterCommand.key)
if (!added) return false
}
commands.call(goToNextTableCellCommand.key)
return true
return commands.call(goToNextTableCellCommand.key)
},
},
}))

View File

@@ -59,13 +59,26 @@ function isWholeWordAt(text: string, start: number, end: number): boolean {
return !WORD_CHAR.test(before) && !WORD_CHAR.test(after)
}
function lowerPreservingLength(text: string): string {
// 逐字小写化且保持长度:个别字符(如 İ)小写后变 2 个码元,
// 整串 toLowerCase 会让其后所有下标与 positions 错位,这里把这类字符保持原样。
let out = ''
for (const ch of text) {
const lower = ch.toLowerCase()
out += lower.length === ch.length ? lower : ch
}
return out
}
export function findInIndex(index: TextIndex, query: string, opts: FindOptions): MatchRange[] {
if (!query) return []
const ranges: { start: number; end: number }[] = []
if (opts.regex) {
let re: RegExp
try {
re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi')
// 整词对正则同样生效:把模式包进 \b 边界(原先静默忽略)
const pattern = opts.wholeWord ? `\\b(?:${query})\\b` : query
re = new RegExp(pattern, opts.caseSensitive ? 'g' : 'gi')
} catch (e) {
throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`)
}
@@ -79,8 +92,8 @@ export function findInIndex(index: TextIndex, query: string, opts: FindOptions):
if (ranges.length >= 5000) break
}
} else {
const haystack = opts.caseSensitive ? index.text : index.text.toLowerCase()
const needle = opts.caseSensitive ? query : query.toLowerCase()
const haystack = opts.caseSensitive ? index.text : lowerPreservingLength(index.text)
const needle = opts.caseSensitive ? query : lowerPreservingLength(query)
let from = 0
while (from <= haystack.length - needle.length) {
const hit = haystack.indexOf(needle, from)
@@ -96,6 +109,13 @@ export function findInIndex(index: TextIndex, query: string, opts: FindOptions):
const fromPos = index.positions[r.start]
const toPos = index.positions[r.end - 1]
if (fromPos < 0 || toPos < 0) continue // 跨块命中,跳过
// 端点都在真实文本上、但中段横跨块间分隔符的命中(如正则 foo\s+bar 跨段)同样跳过:
// 只查首尾端点会放行这类命中,替换时将把两段合并。
let spansSeparator = false
for (let i = r.start; i < r.end; i++) {
if (index.positions[i] < 0) { spansSeparator = true; break }
}
if (spansSeparator) continue
out.push({ ...r, from: fromPos, to: toPos + 1 })
}
return out

View File

@@ -93,7 +93,7 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
let rawHtml: string | null = null
if (job.lang === 'mermaid' && mermaidApi) {
try {
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}`, job.code)
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}-${jobs.indexOf(job)}`, job.code)
rawHtml = `<div class="mermaid-diagram">${svg}</div>`
} catch {
rawHtml = null
@@ -188,7 +188,9 @@ export async function markdownToExportHtml(markdown: string, opts: ExportOptions
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
// throwOnError:false —— 单个坏公式降级显示原文而不是让整篇导出失败。
// 插件处 as never 是为绕开 unified 对该插件 Options 的重载推断误判(运行时为标准两参调用)。
.use(rehypeKatex as never, { throwOnError: false })
const tree = (await processor.run(processor.parse(markdown))) as unknown as Root
await postProcess(tree, opts)
const body = await unified()

View File

@@ -89,9 +89,23 @@ export async function copyToAssets(source: string, docPath: string): Promise<str
return invoke<string>('copy_to_assets', { source, docPath })
}
/** 把字节(粘贴/拖入的图片)写入文档资源目录,返回相对路径。 */
export async function saveBytesToAssets(docPath: string, fileName: string, bytes: number[]): Promise<string> {
return invoke<string>('save_bytes_to_assets', { docPath, fileName, bytes })
/** 把字节(粘贴/拖入的图片)以 base64 写入文档资源目录,返回相对路径。
* 审计修复:原先传 number[],IPC 按 JSON 数字数组序列化,大图成本放大一个量级。 */
export async function saveBytesToAssets(docPath: string, fileName: string, base64: string): Promise<string> {
return invoke<string>('save_bytes_to_assets', { docPath, fileName, base64 })
}
/** File → base64(不带 data: 前缀)。 */
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const url = String(reader.result ?? '')
resolve(url.slice(url.indexOf(',') + 1))
}
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'))
reader.readAsDataURL(file)
})
}
/** Web 降级:选图片并读为 data URL。 */
@@ -165,8 +179,9 @@ export async function renamePath(from: string, to: string): Promise<void> {
return invoke<void>('rename_path', { from, to })
}
export async function deleteToTrash(path: string): Promise<void> {
return invoke<void>('delete_to_trash', { path })
/** 删除到回收站;返回实际去向 'trash' | 'permanent'(回收站不可用时永久删除,调用方应明示用户)。 */
export async function deleteToTrash(path: string): Promise<string> {
return invoke<string>('delete_to_trash', { path })
}
export interface RecentEntry {

View File

@@ -46,7 +46,7 @@ export type TabsAction =
| { type: 'open'; tab: DocTab }
| { type: 'activate'; id: string }
| { type: 'updateContent'; id: string; content: string }
| { type: 'saved'; id: string; path?: string; title?: string }
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
| { type: 'setPath'; id: string; path: string; title: string }
| { type: 'close'; id: string }
@@ -66,17 +66,21 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
}
case 'activate':
return state.tabs.some((t) => t.id === action.id) ? { ...state, activeId: action.id } : state
case 'updateContent':
case 'updateContent': {
// 内容未变时返回原 state:序列化回调偶尔会回放相同文本,避免整树无谓重渲染
const existing = state.tabs.find((t) => t.id === action.id)
if (existing && existing.content === action.content) return state
return {
...state,
tabs: state.tabs.map((t) => (t.id === action.id ? { ...t, content: action.content } : t)),
}
}
case 'saved':
return {
...state,
tabs: state.tabs.map((t) =>
t.id === action.id
? { ...t, savedContent: t.content, path: action.path ?? t.path, title: action.title ?? t.title }
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title }
: t,
),
}

View File

@@ -89,7 +89,7 @@ button { font: inherit; color: inherit; }
.tree-item.active { background: var(--accent-weak); }
.tree-icon { color: var(--text-dim); width: 12px; flex: none; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; position: relative; }
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; }
.source-cm .cm-editor { height: 100%; outline: none; }
@@ -148,7 +148,6 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.mermaid-preview.mermaid-error { color: #b3261e; font-size: 13px; text-align: left; white-space: pre-wrap; }
.mermaid-block pre { margin: 0 !important; border-radius: 0 !important; }
.app-notice { position: fixed; right: 14px; bottom: 40px; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: 0 8px 22px rgba(0,0,0,0.14); max-width: 420px; }
.modal-note { color: var(--text-dim); font-size: 13px; }
.recovery-list { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow: auto; }
.recovery-list li { display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.recovery-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
@@ -178,6 +177,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
--doc-font: -apple-system, "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-lh: 1.62;
--doc-link: #0969da;
--doc-paper: #ffffff;
--doc-ink: #1f2328;
--doc-code-bg: #f6f8fa;
--doc-h-border: #d8dee4;
}
@@ -185,6 +186,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
--doc-font: ui-sans-serif, "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-lh: 1.66;
--doc-paper: #fbfbfa;
--doc-ink: #37352f;
--doc-quote-color: #6f6e69;
--doc-quote-border: #e3e2de;
--doc-code-bg: #f7f6f3;
--doc-h-border: transparent;
@@ -250,7 +253,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */
@media print {
.titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu,
.table-toolbar, .tree-context-menu { display: none !important; }
.table-toolbar, .tree-context-menu, .findbar, .mermaid-head { display: none !important; }
.app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
.editor-scroll { background: #fff !important; }
.milkdown .ProseMirror { font-size: 11.5pt; }
@@ -277,7 +280,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.ws-search-hit:hover { background: var(--accent-weak); }
.ws-search-line { color: var(--text-dim); flex: none; min-width: 26px; text-align: right; }
.ws-search-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.findbar { position: absolute; top: 8px; right: 14px; z-index: 40; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; min-width: 380px; }
.findbar { position: absolute; top: 8px; right: 14px; z-index: 40; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; min-width: 340px; max-width: calc(100% - 24px); }
.findbar-row { display: flex; align-items: center; gap: 5px; }
.find-input { flex: 1; min-width: 130px; height: 28px; }
.find-toggle { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 3px 7px; font-size: 12px; cursor: pointer; color: var(--text-dim); }
@@ -285,7 +288,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.find-icon { border: 0; background: transparent; border-radius: 6px; padding: 3px 7px; cursor: pointer; color: var(--text-dim); }
.find-icon:hover { background: var(--accent-weak); }
.find-count { font-size: 12px; color: var(--text-dim); min-width: 74px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.find-action { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; }
.find-action { border: 1px solid var(--border); background: var(--bg-panel); border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; }
.find-action:hover { background: var(--accent-weak); }
.find-spacer { width: 26px; flex: none; }
.focus-dim { opacity: 0.32; transition: opacity 0.25s ease; }
@@ -293,9 +296,30 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.shortcut-row { display: flex; align-items: center; gap: 7px; padding: 4px 2px; border-bottom: 1px solid var(--border); font-size: 13px; }
.shortcut-row .shortcut-name { flex: 1; }
.shortcut-row kbd { min-width: 118px; text-align: center; }
.shortcut-row button { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
.shortcut-row button { border: 1px solid var(--border); background: var(--bg-panel); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
.shortcut-row button:disabled { opacity: 0.4; cursor: default; }
.shortcut-subtitle { margin: 12px 0 4px; font-size: 13px; }
.conflict-note { display: flex; align-items: center; gap: 8px; }
.findbar { position: absolute; }
.editor-scroll { position: relative; }
/* ——— 审计修复:统一输入框基础样式(.input 此前只有布局规则没有外观,
查找框/搜索框/弹窗输入框全是原生外观,深色下白底黑字) ——— */
.input {
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 6px;
padding: 5px 9px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-weak); }
.input::placeholder { color: var(--text-dim); }
/* ——— 审计修复:全应用滚动条与选区(此前全是 WebView 默认样式,深色下割裂) ——— */
::selection { background: var(--accent-weak); }
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-corner { background: transparent; }

View File

@@ -4,18 +4,19 @@ export interface DocTheme {
id: string
name: string
description: string
/** 该主题自带深色纸面(编辑区强制深色,不随 UI 明暗) */
darkPaper?: boolean
/** 主题强制的纸面明暗:源码模式据此选 CodeMirror 明暗,避免深纸面配深字。
* 未设时纸面跟随 UI 明暗。原 darkPaper 字段无人消费,已由 paperTone 取代。 */
paperTone?: 'light' | 'dark'
}
export const DOC_THEMES: DocTheme[] = [
{ id: 'default', name: '默认', description: '石板中性、适度行距,当前默认排版' },
{ id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接' },
{ id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔' },
{ id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接', paperTone: 'light' },
{ id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔', paperTone: 'light' },
{ id: 'simple-blue', name: '简约蓝', description: '蓝色标题与链接,清爽的轻量排版' },
{ id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', darkPaper: true },
{ id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', paperTone: 'dark' },
{ id: 'focus', name: '专注写作', description: '衬线正文、加宽行距、窄栏,适合长文写作' },
{ id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感' },
{ id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感', paperTone: 'light' },
]
export function docThemeById(id: string): DocTheme {

View File

@@ -47,11 +47,17 @@ export function comboFromEvent(e: { ctrlKey: boolean; metaKey: boolean; altKey:
if (['Control', 'Meta', 'Alt', 'Shift'].includes(key)) return null
let main = key.length === 1 ? key.toLowerCase() : key.toLowerCase()
if (main === ' ') main = 'space'
if (main === '+') main = '='
let shift = e.shiftKey
if (main === '+') {
// '+' 本就是 Shift+'=' 的产物:归一为不带 shift 的 '=',
// 否则按 Ctrl+Shift+= 录入的组合永远匹配不上默认的 ctrl+=(缩放)。
main = '='
shift = false
}
const parts: string[] = []
if (e.ctrlKey || e.metaKey) parts.push('ctrl')
if (e.altKey) parts.push('alt')
if (e.shiftKey) parts.push('shift')
if (shift) parts.push('shift')
parts.push(main)
return parts.join('+')
}

102
tests/audit-fix.test.ts Normal file
View File

@@ -0,0 +1,102 @@
// 审计修复回归测试(2026-11-13,对应 docs/11 的高危/中危项)
import { describe, it, expect } from 'vitest'
import { createMarkdownEditor, cursorFromSelection } from '../src/editor/editor'
import { editorViewCtx } from '@milkdown/kit/core'
import { AllSelection, NodeSelection } from '@milkdown/kit/prose/state'
import { buildTextIndex, findInIndex } from '../src/editor/search'
import { insertPlainText } from '../src/editor/paste'
import { findBlockIndex } from '../src/editor/cursorSync'
import { getMarkdown } from '../src/editor/editor'
async function makeEditor(markdown: string) {
const host = document.createElement('div')
document.body.appendChild(host)
const editor = createMarkdownEditor(host, markdown)
await editor.create()
return { host, editor }
}
function docOf(editor: ReturnType<typeof createMarkdownEditor>) {
return editor.action((ctx) => ctx.get(editorViewCtx).state.doc)
}
describe('cursorFromSelection 的 depth=0 选区(审计高危 6)', () => {
it('全选时不抛 RangeError', async () => {
const { host, editor } = await makeEditor('第一段\n\n第二段')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
expect(() => cursorFromSelection(new AllSelection(view.state.doc))).not.toThrow()
})
await editor.destroy()
host.remove()
})
it('选中公式块(NodeSelection)时不抛 RangeError', async () => {
const { host, editor } = await makeEditor('前文\n\n$$\nx=1\n$$\n\n后文')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
let mathPos = -1
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'math_block') mathPos = pos
return mathPos < 0
})
expect(mathPos).toBeGreaterThanOrEqual(0)
const info = cursorFromSelection(new NodeSelection(view.state.doc.resolve(mathPos)))
expect(info.line).toBeGreaterThanOrEqual(1)
})
await editor.destroy()
host.remove()
})
})
describe('查找索引修复(审计)', () => {
it('正则命中横跨块间分隔符时被跳过', async () => {
const { host, editor } = await makeEditor('foo\n\nbar baz')
const index = buildTextIndex(docOf(editor) as never)
const cross = findInIndex(index, 'foo\\s+bar', { caseSensitive: false, wholeWord: false, regex: true })
expect(cross).toHaveLength(0)
const within = findInIndex(index, 'bar', { caseSensitive: false, wholeWord: false, regex: false })
expect(within).toHaveLength(1)
await editor.destroy()
host.remove()
})
it('整词对正则模式同样生效', async () => {
const { host, editor } = await makeEditor('hello world hello shell')
const index = buildTextIndex(docOf(editor) as never)
expect(findInIndex(index, 'hello', { caseSensitive: true, wholeWord: true, regex: true })).toHaveLength(2)
expect(findInIndex(index, 'hell', { caseSensitive: true, wholeWord: true, regex: true })).toHaveLength(0)
await editor.destroy()
host.remove()
})
it('含 İ 的文档不区分大小写查找位置不偏移', async () => {
const { host, editor } = await makeEditor('İ x hello')
const index = buildTextIndex(docOf(editor) as never)
const hits = findInIndex(index, 'hello', { caseSensitive: false, wholeWord: false, regex: false })
expect(hits).toHaveLength(1)
// 段落内容在文档中从位置 1 开始,'İ x ' 占 4 个字符,hello 应落在 5
expect(hits[0].from).toBe(5)
await editor.destroy()
host.remove()
})
})
describe('粘贴与锚点修复(审计)', () => {
it('多行纯文本粘贴按段落插入,不塞进单个文本节点', async () => {
const { host, editor } = await makeEditor('开头')
insertPlainText(editor, '第一行\n第二行')
const md = getMarkdown(editor)
expect(md).toContain('第一行')
expect(md).toContain('第二行')
// 两行应成为两个段落(序列化里是空行分隔),而不是一行里的字面换行
expect(md.replace(/\n+$/, '')).toMatch(/第一行\n\n/)
await editor.destroy()
host.remove()
})
it('锚点兜底匹配不被空段落劫持', () => {
const blocks = ['', '目标文本 abc', '其他']
expect(findBlockIndex(blocks, { blockText: '目标文本', offsetInBlock: 0 })).toBe(1)
})
})

View File

@@ -91,7 +91,7 @@ describe('快捷键注册表', () => {
it('事件组合键归一化与展示', () => {
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: 'S' })).toBe('ctrl+shift+s')
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+shift+=')
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+=')
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, key: 'Control' })).toBeNull()
expect(prettyCombo('ctrl+shift+s')).toBe('Ctrl+Shift+S')
expect(prettyCombo('')).toBe('(未绑定)')

80
tests/tabkey.test.ts Normal file
View File

@@ -0,0 +1,80 @@
// Tab 键集成测试(审计 E1 定性):向组装好的编辑器派发真实 keydown 事件,
// 验证表格跳格 / 代码块行首缩进到底由哪个插件处理——此前的测试都是直调函数,
// 从未验证过 Milkdown 自带 indent 插件(对 Tab 无条件插两空格)是否抢先。
import { describe, it, expect } from 'vitest'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
const MD = [
'普通段落文字。',
'',
'| A | B |',
'| --- | --- |',
'| 甲一 | 乙二 |',
'',
'```ts',
'const x = 1',
'```',
'',
].join('\n')
function dispatchTab(host: HTMLElement, editor: ReturnType<typeof createMarkdownEditor>, shift = false) {
const view = editor.action((ctx) => ctx.get(editorViewCtx))
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: shift, bubbles: true, cancelable: true })
view.dom.dispatchEvent(event)
return event
}
function setCursorIn(editor: ReturnType<typeof createMarkdownEditor>, needle: string, offsetInText = 1) {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
let target = -1
view.state.doc.descendants((node, pos) => {
if (target < 0 && node.isText && node.text?.includes(needle)) {
target = pos + node.text.indexOf(needle) + offsetInText
}
return target < 0
})
if (target < 0) throw new Error(`找不到文本 ${needle}`)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, target)))
view.focus()
})
}
function cursorCellText(editor: ReturnType<typeof createMarkdownEditor>): string {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
return view.state.selection.$from.parent.textContent
})
}
describe('Tab 键真实派发', () => {
it('表格内 Tab 跳到下一个单元格而不是插入空格', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const editor = createMarkdownEditor(host, MD)
await editor.create()
setCursorIn(editor, '甲一')
const before = getMarkdown(editor)
dispatchTab(host, editor)
expect(cursorCellText(editor)).toBe('乙二')
expect(getMarkdown(editor)).toBe(before) // 文档未被插入空格改动
await editor.destroy()
host.remove()
})
it('代码块内 Tab 在行首缩进(光标在行中时)', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const editor = createMarkdownEditor(host, MD)
await editor.create()
setCursorIn(editor, 'const x = 1', 4) // 光标停在 "con|st" 处
dispatchTab(host, editor)
const md = getMarkdown(editor)
expect(md).toContain(' const x = 1') // 行首多了两空格
expect(md).not.toContain('con st') // 不是在光标处插空格
await editor.destroy()
host.remove()
})
})

View File

@@ -24,11 +24,21 @@ describe('标签页会话模型', () => {
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '原文' })
expect(isDirty(s.tabs[0])).toBe(false)
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '新版' })
s = tabsReducer(s, { type: 'saved', id: t.id })
s = tabsReducer(s, { type: 'saved', id: t.id, savedContent: '新版' })
expect(isDirty(s.tabs[0])).toBe(false)
expect(s.tabs[0].savedContent).toBe('新版')
})
it('保存竞态:写盘期间的新输入不被 saved 误标(审计回归)', () => {
const t = createTab({ content: '快照' })
let s: TabsState = { tabs: [t], activeId: t.id }
// 写盘用的是「快照」;await 期间用户又输入了新内容
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '快照+新输入' })
s = tabsReducer(s, { type: 'saved', id: t.id, savedContent: '快照' })
expect(s.tabs[0].savedContent).toBe('快照')
expect(isDirty(s.tabs[0])).toBe(true) // 新输入仍是脏的,等待下一次保存
})
it('关闭活动标签后激活相邻标签;关闭其他/右侧范围正确', () => {
let s = stateWith(['a', 'b', 'c'])
const [t1, t2, t3] = s.tabs