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:
64
docs/12-audit-fix-report.md
Normal file
64
docs/12-audit-fix-report.md
Normal 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 构建(沙箱限制,同前)。
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
227
src/App.tsx
227
src/App.tsx
@@ -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'}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}」占用。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
102
tests/audit-fix.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
80
tests/tabkey.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user