This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

453
src/ai/ai-chat-panel.js Normal file
View File

@@ -0,0 +1,453 @@
// AI 对话面板(底部 dock)
// ============================================================================
//
// 给一段自然语言修改要求 + 当前文件内容,发给主进程调 AI。
// 状态机:idle → submitting → (success | error | cancelled) → idle
// 不持有 diff 数据(diff 走 AiDiffPanel / AiController)。
//
// 职责边界:
// - 状态展示(status / error 通过 onNotify 转发到状态栏 #status-ai chip)
// - 收集用户输入
// - 防 IME(compositionstart/end 期间不响应 Enter)
// - 失败重试:失败后用户改完提示词直接按 Enter 重新提交,不用关面板
//
// 状态展示迁移([feedback-ai-tips-into-status-bar]):原本写在自己
// .ai-chat-status-row 的过程/错误文案,现在通过 onNotify(text, type) 转发
// 给外部(通常是 statusbar 上的 AI chip)。dock 本身只剩输入行,不再占底部一行。
// ============================================================================
import { AI_ERROR } from './ai-status.js';
function createRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `ai-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* 长提示词软警告阈值(按字符计,UTF-8 近似字节;CJK 是 3 字节/字符,这里保守
* 按 ASCII 1 字节算,实际 CJK 阈值会更大一些,方向是「宁可漏报也不骚扰」)。
* 32KB 是经验阈值:超过后多数模型服务开始明显变慢,部分服务开始截断。
*/
const LONG_PROMPT_BYTES = 32 * 1024;
/**
* 把 controller 的中性状态文案映射到 chip 的视觉类型。
* panel 这一侧不深推;不识别就回退 'success'(中性 muted)。
* - 空文本 → 'idle'(chip 回到"无动作"中性态,只显示 "AI" 标签)
* - '正在发送...' → running
* - '正在取消...' → running(用户操作中)
* - '已取消...' → cancelled
* 其它走 success。controller 在三处显式调 onNotify(_, 'diff'),不走这条路径。
*
* 注:空文本返回 'success' 会让 chip 的 success dot 一直亮着,掩盖「已回到
* 无动作态」的信号 —— 用户看到 chip 高亮会以为还有事在跑。所以空文本走 'idle'.
*
* @param {string} text
* @returns {'idle'|'running'|'cancelled'|'success'}
*/
function inferStatusType(text) {
if (!text) return 'idle';
if (text.indexOf('正在发送') === 0 || text.indexOf('正在取消') === 0) return 'running';
if (text.indexOf('已取消') === 0) return 'cancelled';
// 'info' 是 panel 主动调的(如长提示词提示),不参与被动推断
return 'success';
}
export class AiChatPanel {
/**
* @param {object} options
* @param {HTMLElement} options.element - 容器 div(已在 index.html 写好骨架)
* @param {(payload: {prompt:string, requestId:string}) => Promise<{ok:boolean, id?:string, content?:string, responseFormat?:string, error?:string, message?:string}>} options.onSubmit
* @param {(requestId:string) => void} options.onCancel
* @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify]
* 把过程/错误文案转发到外部 UI(statusbar 上的 #status-ai chip)。不传则静默丢弃。
*/
constructor({ element, onSubmit, onCancel, onNotify }) {
this.element = element;
this.onSubmit = onSubmit;
this.onCancel = onCancel;
this.onNotify = typeof onNotify === 'function' ? onNotify : null;
this._open = false;
this._enabled = true;
this._submitting = false;
this._composing = false;
this._currentRequestId = null;
this._cancelled = null; // Set:被用户取消的 requestId 集合;懒创建于 _triggerCancel
this._listeners = [];
this._bind();
}
_bind() {
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
const toggleBtn = this.element.querySelector('.ai-chat-toggle');
if (input) {
this._listeners.push([input, 'input', () => {
this._syncSubmitButton();
this._syncLongPromptHint();
}]);
this._listeners.push([input, 'compositionstart', () => { this._composing = true; }]);
this._listeners.push([input, 'compositionend', () => { this._composing = false; }]);
// 兜底:IME 异常不发 compositionend 时,_composing 会永远卡 true,
// 导致 Enter 永久失效。blur 时强制重置(用户切走时输入已结束)。
this._listeners.push([input, 'blur', () => { this._composing = false; }]);
this._listeners.push([input, 'keydown', (e) => {
// Enter = 提交(不 Shift);IME 输入中不发
if (e.key === 'Enter' && !e.shiftKey && !this._composing) {
e.preventDefault();
this._triggerSubmit();
}
}]);
}
// 没有 .ai-chat-form 包装时(index.html 当前结构),按钮点击就是触发的来源
if (submitBtn) {
this._listeners.push([submitBtn, 'click', (e) => {
// submitting 时:点击 = 取消(需要在 _triggerSubmit 之前 preventDefault)
if (this._submitting) {
e.preventDefault();
this._triggerCancel();
return;
}
// 否则:提交
e.preventDefault();
this._triggerSubmit();
}]);
}
if (toggleBtn) {
this._listeners.push([toggleBtn, 'click', () => this.close()]);
}
for (const [el, ev, fn] of this._listeners) {
el.addEventListener(ev, fn);
}
}
open() {
this._open = true;
this.element.hidden = false;
// C1 fix (audit):close→open 路径上若 _submitting 状态残留(IPCF 还没回包),
// input.disabled 仍为 true,输入框会被永久禁用。这里走 _inputShouldBeDisabled()
// 合并所有禁用条件,_submitting 自身的回包路径仍会通过 finally 再次同步状态。
const input = this.element.querySelector('.ai-chat-input');
if (input) input.disabled = this._inputShouldBeDisabled();
this._syncSubmitButton();
// 自动聚焦输入框(用户点按钮来开)
requestAnimationFrame(() => {
const el = this.element.querySelector('.ai-chat-input');
if (el && !el.disabled) el.focus();
});
}
close() {
this._open = false;
this.element.hidden = true;
// 关闭时若还有请求:撤销
if (this._submitting) this._triggerCancel();
// P3 fix:hung 请求兜底 —— _triggerCancel 只是把 cancel 发给主进程;如果 AI
// 服务永远不回包,_triggerSubmit 的 await 永远不会 resolve,finally 永远跑不到,
// _submitting 卡在 true。close 不强制复位,下次 open 时用户按提交会被
// `if (this._submitting) return` 挡住、误以为按钮坏了。
// audit fix (Phase O-L18):原 `if (this._submitting && this._canceling)` 是
// 死代码 —— _canceling 从未置 true(line 376-377 注释明确说不设),整个 if
// 分支永远不进。简化:仅看 _submitting;notify 不动(让残留的 in-flight
// 旧请求自己跑完)。输入框 / 输入按钮的 disabled 由下次 open() + setEnabled() 同步。
if (this._submitting) {
this._submitting = false;
this._currentRequestId = null;
this._syncSubmitButton();
}
// 关掉 dock 时清掉状态栏 chip —— 否则 chip 会留下上次的"已应用"或"AI 请求失败"
// 残留,让用户误以为是新请求的结果
this._notify('', 'idle');
}
isOpen() {
return this._open;
}
/**
* 当主进程给出新内容(成功路径),清空输入框。
* 失败 / 取消保留输入框,方便用户改完直接重试。
*/
resetInput() {
const input = this.element.querySelector('.ai-chat-input');
if (input) input.value = '';
this._syncSubmitButton();
}
/**
* 强制重置整个面板状态(status / error / submitting / 输入框 disabled)。
* controller 在 onFileChanged / closeAll 时调用,避免切文件后残留
* "正在提交…" 状态 / 错误行。
*
* Bug-3 fix (audit):同时清掉 _cancelled Set —— 切文件/重置时可能还有
* "用户取消但 await 还没回" 的 requestId 留在 Set 里。reset 不清的话:
* 1. 用户在新文件重新提交 → 触发 _triggerSubmit 的 cancelled.has(requestId)
* 检查时,可能误命中旧 requestId(UUID 冲突概率虽低,但存在);
* 2. Set 只增不减,长期使用会有轻微内存泄漏。
* _cancelled 是懒创建(_triggerCancel 第一次调用时),所以这里要 lazy-init。
*/
reset() {
this._submitting = false;
this._canceling = false;
this._currentRequestId = null;
if (this._cancelled) this._cancelled.clear();
const input = this.element.querySelector('.ai-chat-input');
if (input) {
input.disabled = !this._enabled;
input.value = '';
}
this._notify('', 'idle');
this._syncSubmitButton();
}
/**
* 没有打开文件 / 没有可修改内容时,禁用面板。
* @param {boolean} enabled
*/
setEnabled(enabled) {
this._enabled = !!enabled;
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
if (input) {
input.disabled = this._inputShouldBeDisabled();
input.placeholder = this._enabled ? '描述你想如何修改当前文档...' : '请先打开一个 Markdown 文件';
}
if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled();
}
/**
* 单一来源:输入框 / 提交按钮的 disabled 取值。
* 把"未启用 + 提交中 + 空输入"三个条件集中在一处 —— open() / setEnabled() /
* _syncSubmitButton() / finally 路径都走这里,避免某条路径漏合并 _submitting 状态
* 而让用户在 AI 响应期间又能输入新内容(会让新输入框内容与正在响应的内容竞速)。
* @returns {boolean}
*/
_inputShouldBeDisabled() {
return !this._enabled || !!this._submitting;
}
/**
* 应用启动后调用:根据"是否有打开的文件"同步 disabled。
* @param {boolean} hasFile
*/
syncFileState(hasFile) {
this.setEnabled(hasFile);
}
_syncSubmitButton() {
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
if (!submitBtn) return;
if (this._submitting) {
submitBtn.textContent = '取消';
submitBtn.dataset.mode = 'cancel';
// a11y #1(critical):按钮文字在「生成修改 / 取消」之间切换时,
// aria-label 必须同步 —— 屏幕阅读器只读 aria-label,不读 textContent,
// 否则用户听到的还是 "生成修改",与可见状态完全失配。
submitBtn.setAttribute('aria-label', '取消 AI 请求');
} else {
const empty = !input || input.value.trim().length === 0;
submitBtn.textContent = '生成修改';
submitBtn.dataset.mode = 'submit';
submitBtn.setAttribute('aria-label', '生成 AI 修改');
// 非 submitting 时只受「未启用 + 空输入」影响(_inputShouldBeDisabled 已隐含 !_submitting)
submitBtn.disabled = !this._enabled || empty;
}
}
/**
* audit fix (Phase L3-AI 1):超长提示词的软警告。
*
* 用户在 AI dock 里粘贴一大段(比如复制整个章节当 prompt),主进程要把全文
* 拼进 IPC payload 一次性送给模型服务。提示词越长,IPC 序列化 / 网络上传 /
* 模型处理时间都线性涨,且超出模型上下文窗口会被服务端截断甚至 400。
*
* UX:仅在 input value 跨过 LONG_PROMPT_BYTES(≈32KB)阈值时,向 onNotify
* 推一条短提示到 statusbar 的 #status-ai chip;用户删回阈值下后立刻清回
* idle(不污染后续 submit/cancel 的 chip 文案)。
*
* 注意:不算 input.value 实际 trim 后的 prompt 字节——用户粘的是「即将提交的
* 文本」,input.value 是最接近的真相;trim 后的差异在 32KB 量级上无意义。
* 不要在这里 throw —— 监听器抛错会让 input 整体崩(绑定时未用 { signal },
* addEventListener 会把异常往上抛到 dispatch)。
*/
_syncLongPromptHint() {
const input = this.element.querySelector('.ai-chat-input');
if (!input) return;
// chip 已被 submit / cancel / error 占用时不抢戏;只在 idle 状态下显示提示
if (this._submitting) return;
const len = input.value.length;
if (len >= LONG_PROMPT_BYTES) {
const kb = Math.round(len / 1024);
this._notify(`提示词较长(约 ${kb} KB),AI 响应可能变慢`, 'info');
} else if (len === 0) {
// 空输入让 chip 回到 idle;用户已清空提示词就别再保留长提示
this._notify('', 'idle');
}
// 中间区间(>0 且 < 阈值)保持现状:不打扰用户
}
async _triggerSubmit() {
if (!this.onSubmit) return;
if (this._submitting) return;
if (!this._enabled) return;
const input = this.element.querySelector('.ai-chat-input');
const prompt = (input && input.value || '').trim();
if (!prompt) {
// M7 fix (audit):守卫失败的分支也会被 Enter 触发(例如上一次 success 后用户
// 直接回车提交空字符串)。早返回前把 chip 清回 idle,避免 chip 残留上一次的
// success / running 视觉误导用户以为"还有事在跑"或"已经提交过了"。
this._notify('', 'idle');
return;
}
const requestId = createRequestId();
this._currentRequestId = requestId;
this._submitting = true;
this._canceling = false;
this._notify('正在发送请求…', 'running');
this._syncSubmitButton();
if (input) input.disabled = true;
try {
const result = await this.onSubmit({ prompt, requestId });
// 已被新请求顶掉(用户点了"取消"或再次提交):静默
if (this._currentRequestId !== requestId) return;
// P1-3 fix (audit):用户主动点了「取消」→ 忽略该 requestId 的响应,
// 不 resetInput,不展示 AI 结果。
if (this._cancelled && this._cancelled.has(requestId)) {
this._cancelled.delete(requestId);
this._notify('已取消 AI 请求', 'cancelled');
return;
}
if (result && result.ok) {
// 成功状态文案由 controller 算(依赖是否有 diff)后调用 setStatus
this.resetInput();
// 输入框保持 disabled 由 setEnabled 决定
} else if (result && result.error === AI_ERROR.ERR_CANCELLED) {
this._notify('已取消 AI 请求', 'cancelled');
} else {
const msg = (result && result.message) ? result.message : 'AI 修改失败';
this._notify(msg, 'error');
}
} catch (e) {
if (this._currentRequestId !== requestId) return;
// L17 fix (audit):catch 分支的 e.message 可能来自 IPC 序列化失败 / 主进程
// 抛错 / controller 内部 bug,里面可能含路径 / stack / 内部常量。与其
// echo 给用户(泄露内部细节),不如走中性文案 + console 留痕供排查。
console.error('[ai-chat-panel] onSubmit threw:', e);
this._notify('AI 修改失败,请稍后重试', 'error');
} finally {
if (this._currentRequestId === requestId) {
this._submitting = false;
this._canceling = false;
this._currentRequestId = null;
const i = this.element.querySelector('.ai-chat-input');
if (i) i.disabled = !this._enabled;
this._syncSubmitButton();
}
}
}
_triggerCancel() {
if (!this._submitting) return;
if (!this._currentRequestId) return;
const oldRequestId = this._currentRequestId;
// P1-3 fix (audit):先标记该 requestId 为 cancelled,让 _triggerSubmit
// 后续 await 返回时直接丢弃(不 resetInput / 不应用结果)。
if (!this._cancelled) this._cancelled = new Set();
this._cancelled.add(oldRequestId);
// P2-1 fix (audit):onCancel 抛错不要立即写 error 行 —— 让 _triggerSubmit
// 的 result 分支统一根据 error code 决定显示「已取消」或「失败」。
try {
if (this.onCancel) this.onCancel(oldRequestId);
} catch (e) {
console.warn('[ai-chat-panel] onCancel threw:', e);
}
// audit fix (Phase L3-RACE 1):cancel 后立刻允许用户提交新 prompt,不必等
// 主进程响应真正 abort 后 _triggerSubmit 的 finally 才复位 _submitting。
// 旧 _triggerSubmit 的 finally 因 currentRequestId !== requestId 会跳过状态
// 重置(看下面那个 `if (this._currentRequestId === requestId)` 守卫),
// 所以提前清是安全的;用户立刻打新 prompt + Enter 不会被 `if (this._submitting) return` 拦住。
// 真正的取消通知由旧 _triggerSubmit 的 _cancelled.has 分支产生 '已取消 AI 请求'。
this._submitting = false;
this._currentRequestId = null;
this._notify('已取消 AI 请求', 'cancelled');
const input = this.element.querySelector('.ai-chat-input');
if (input) input.disabled = !this._enabled;
this._syncSubmitButton();
}
/**
* controller 调:强制取消(用于切文件等场景)—— 即使 chatPanel 自己不知道
* 这次提交是否还"在 submitting 状态",都把 IPC cancel 发出去。
* @param {string} requestId
*/
cancelRequest(requestId) {
if (typeof requestId === 'string' && requestId) {
// L6 fix (audit):先把 requestId 加进 _cancelled —— 即使 IPC aiCancel 失败
// (main 端找不到 pending、abort 没赶上),_triggerSubmit 的 await 返回时
// 仍会命中 _cancelled.has() 短路,显示「已取消」而不是误导性错误信息。
if (!this._cancelled) this._cancelled = new Set();
this._cancelled.add(requestId);
if (this.onCancel) {
try {
this.onCancel(requestId);
} catch (e) {
// audit fix (1.2):controller 在切文件 / 用户强退等关键路径调用本方法,
// 即便 IPC 失败也不应给用户弹红色错误行(瞬时态冲突、误导)。
// 只在 console 留痕即可。
console.warn('[ai-chat-panel] cancelRequest 抛错:', e);
}
}
}
}
/**
* controller 调:更新 chip 状态文案(兼容层 —— 旧 controller 路径会继续用)。
* 内部按文案前缀推断 chip 视觉类型(running / cancelled / success)。
* 若 controller 想强制类型(如 'diff'),请直接调 onNotify。
* @param {string} text
*/
setStatus(text) {
this._notify(text || '', inferStatusType(text));
}
/**
* controller 调:把错误文案写到 chip(danger 色)。
* @param {string} text
*/
setError(text) {
this._notify(text || '', 'error');
}
/**
* 内部统一出口:把 (text, type) 转发到 onNotify,未注入则静默。
* 见 [feedback-ai-tips-into-status-bar]:所有 AI 相关提示都收进底部状态栏 chip,
* 不再在 dock 底部单开一行。
* @param {string} text
* @param {'idle'|'running'|'cancelled'|'error'|'success'|'diff'} type
*/
_notify(text, type) {
if (this.onNotify) {
try { this.onNotify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ }
}
}
destroy() {
for (const [el, ev, fn] of this._listeners) {
el.removeEventListener(ev, fn);
}
this._listeners = [];
// P3-5 fix (audit):destroy 也要清内部状态,避免 bindMountPoint 重新
// 调用时旧实例残留 _composing / _submitting / _canceling / _currentRequestId
this._composing = false;
this._submitting = false;
this._canceling = false;
this._currentRequestId = null;
this._cancelled = null;
}
}

864
src/ai/ai-controller.js Normal file
View File

@@ -0,0 +1,864 @@
// AI 控制器
// ============================================================================
//
// 把"AI 对话面板 + AI 差异面板 + 编辑器"串成一个状态机。
//
// 状态:
// - chatPanelOpen: bool AI 输入面板是否展开
// - pendingDiff: object|null AI 返回的待应用修改(rows + regions)
// - isSubmitting: bool 正在等主进程回包
//
// 数据流:
// 用户输入 prompt
// → chatPanel.onSubmit(prompt, requestId)
// → window.api.aiEdit({...}) (主进程调 OpenAI 兼容 API)
// → 拿到 { content, responseFormat }
// → computeFullMarkdownDiff(currentContent, content) 算出 regions
// → setPendingDiff({ rows, regions, baseContent, nextContent })
// → diffPanel.setDiff(...) 在中间面板渲染
// 用户点 "应用此处" / "应用全部"
// → editor.applyUserChange(newContent) // 标脏
// → 重算 diff(基于新内容);regions 全部应用完则关掉 diffPanel
//
// 内容变化时(用户继续编辑、撤销/重做、autosave 落地)也要重算 diff,
// 让"应用一部分后还剩多少"始终正确。
// ============================================================================
import { AiChatPanel } from './ai-chat-panel.js';
import { AiDiffPanel } from './ai-diff-panel.js';
import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js';
/**
* 给一段行级 lines 构造只读 context rows(用于 AI 普通回复展示)。
* 行号必须与 shared/markdown-diff.js 的 splitLines 严格一致 —— 否则 viewer
* 行号与 diff 行号错位,撤销 / 光标定位跑偏。
* @param {string} content
* @returns {Array<{id:string, type:'context', newLineNumber:number, segments:Array<{type:'equal', text:string}>}>}
*/
function linesToContextRows(content) {
// H1 fix (audit):用与 splitLines 一致的「normalize + endsWith 处理」逻辑,
// 替代原来的 `/\r\n?|\n/` split。两者对裸 \r 旧 Mac 风格的行号会差 1:
// - 原 split("\r") = ["", ""] → 2 行
// - normalize 后 splitLines("\r") = [""] → 1 行
// renderer 不能 import CJS(shared/* 是 CommonJS),所以内联等价实现。
const normalized = String(content || '').replace(/\r\n?/g, '\n');
const lines = normalized.length === 0
? []
: (normalized.endsWith('\n') ? normalized.slice(0, -1).split('\n') : normalized.split('\n'));
return lines.map((line, index) => ({
id: `readonly-${index}`,
type: 'context',
newLineNumber: index + 1,
segments: [{ type: 'equal', text: line }],
}));
}
/**
* @param {object} options
* @param {object} options.api - window.api
* @param {object} options.editor - MarkdownEditor 实例
* @param {object} options.state - app.js 的 state
* @param {(msg:string, type?:string, duration?:number) => void} options.toast
* @param {() => object} options.getSettings - 读最新设置(settingsStore.getAll)
* @param {() => void} [options.onAfterApply] - 成功 apply region / all 后通知外部刷新 viewer。
* preview 模式下 scheduleLivePreview 只在 split 模式刷新 viewer,AI 改动只能通过
* 这个回调让 viewer 看到(否则用户感觉"点了应用,文件没反应")。
* @param {() => void|Promise<void>} [options.onOpenSettings] - 未配置时调:直接打开设置对话框
* 并把焦点定位到 AI 段。AI 按钮 / 提交时检测到未配置都会触发,比 toast 更直接 —— 让用户
* 能立刻看到「还差什么」而不是看到一条转瞬即逝的警告。
* @param {(text:string, type:import('./ai-status.js').AI_STATUS[keyof import('./ai-status.js').AI_STATUS]) => void} [options.notify]
* 把 AI 过程/结果文案转发到外部 UI(statusbar 上的 #status-ai chip)。由 controller
* 主动调:setStatus / setError 在 chatPanel 内部已经转发到 onNotify,但 controller
* 想明确标注类型(如 AI_STATUS.DIFF)时直接调本回调(绕过 chatPanel 的 inferStatusType)。
* @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时
* togglePanel 会拒绝打开 AI dock 并 toast(双向互斥,避免 AI dock 透过 modal
* 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。
*/
export function createAiController({
api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen,
}) {
if (!api || !api.aiEdit) {
console.error('[ai-controller] api.aiEdit 不可用;preload 没暴露 AI 桥?');
}
if (!editor) throw new Error('[ai-controller] editor 必填');
// 状态
let pendingDiff = null;
let lastAppliedContent = null; // 防止 content 变化时无限重算自己
let currentRequestId = null; // 用于切文件时取消 in-flight 请求(见 onFileChanged)
// P0-1 fix (audit):单调 generation 计数器。handleSubmit 入参时捕获,
// await 后比对 —— 文件切换 / 取消 / 重新提交都会 ++generation 把旧请求废弃。
// 防止 AI 响应覆盖新文件的 pendingDiff(数据丢失风险)。
let requestGeneration = 0;
// 防抖 timer:onContentChanged 不再每个 keystroke 都跑 sync DP
let recomputeTimer = null;
// 安全 notify 闭包:notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。
// controller 在 4 处显式调(AI_STATUS.DIFF / AI_STATUS.IDLE),其余走 chatPanel.setStatus 转发。
const safeNotify = (text, type) => {
if (typeof notify !== 'function') return;
try { notify(text || '', type); } catch { /* 不让 UI 异常阻断 AI 流程 */ }
};
function isChatOpen() {
return chatPanel && chatPanel.isOpen();
}
function isConfigured() {
const s = typeof getSettings === 'function' ? (getSettings() || {}) : {};
// P1-1 fix (audit):renderer 端 aiApiKey 是空字符串(main.js 已过滤),
// 真值标识走 _hasAiKey 布尔位 —— 也兼容旧测试 / 老 settings 直接传 aiApiKey 的情况
const hasKey = !!s._hasAiKey || !!String(s.aiApiKey || '').trim();
return !!(String(s.aiBaseUrl || '').trim() && hasKey && String(s.aiModel || '').trim());
}
function recomputeDiffFromBase() {
// 用户在编辑器里继续改了内容:基于最新内容 + AI 的 nextContent 重算
if (!pendingDiff || pendingDiff.isReadOnly) return;
if (!api.aiDiffCompute) return;
const current = safeGetContent();
if (current === null) return; // editor 抛错已被 safeGetContent 记录,不重算
if (current === lastAppliedContent) return; // 没变化
const opts = pickDiffOptions(current, pendingDiff.nextContent);
let next;
try {
// audit A5:未包 try/catch 会让 sync 抛错冒到 editor.onChange → app.js
// 的 IPC 链断裂。降级为 toast,不影响编辑器继续工作。
next = api.aiDiffCompute(current, pendingDiff.nextContent, opts);
} catch (e) {
if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error');
return;
}
if (!next || !next.regions || next.regions.length === 0) {
// 全部应用完了(或没了)→ 关闭 diff
setPendingDiff(null);
} else {
pendingDiff = { ...pendingDiff, baseContent: current, ...next };
lastAppliedContent = current;
if (diffPanel) diffPanel.setDiff(pendingDiff);
}
syncBodyFlag();
}
function pickDiffOptions(base, next) {
const totalLen = (base || '').length + (next || '').length;
const baseLines = (base || '').split(/\r\n?|\n/).length;
const nextLines = (next || '').split(/\r\n?|\n/).length;
// 超过 80k 字符或 12 万行乘积 → 关闭 token 粒度(避免 DP 爆炸)
return {
tokenDiff: totalLen <= 80_000 && baseLines * nextLines <= 120_000,
};
}
/**
* 取编辑器当前内容。失败返回 null 而非 '':
* 若失败时 fallback 成 '',applyDiff 链路会把真实编辑器内容当成空文本,
* 后续 editor.applyUserChange(nextContent) 就把用户文档替换为 AI 基于空文本生成的内容 —— 静默销毁。
* 调用方必须 null-check 并中止 AI 流程。
*/
function safeGetContent() {
try { return editor.getContent(); }
catch (e) {
// audit fix (Phase O-L20):catch 不直接把 e 整个写 console.warn。
// 写整个 e 会让 e.message(含 CM6 state doc 摘要 / 光标位置)落到主进程
// notes-main.log(用户私有 logs/,同机其他用户不可读但仍属日志积压);
// 改成只打错误名 + 行号,不附 message。
console.warn('[ai-controller] safeGetContent 失败:', e && e.name ? e.name : 'Error');
return null;
}
}
function setPendingDiff(diff) {
pendingDiff = diff;
lastAppliedContent = diff ? safeGetContent() : null;
if (diffPanel) diffPanel.setDiff(diff);
syncBodyFlag();
}
/**
* 校验 pendingDiff 仍然属于当前打开的文件。
* audit H162/H163:之前 handleApplyRegion / handleApplyAll 直接读 safeGetContent(),
* 不检查 pendingDiff 是为哪个文件算的 —— 用户提交 AI → 等响应 → 切到另一个文件 →
* 点「应用」,会把 A 的 diff region 算进 B 的内容里(可能命中 / 可能不命中 →
* 即使命中也是把 A 的修改写到 B,数据丢失)。
* 现在所有 setPendingDiff 都带上 myFile,apply 前再核对一次。
* @returns {{ ok: true } | { ok: false, reason: string }}
*/
function ensurePendingDiffMatchesCurrentFile() {
if (!pendingDiff) return { ok: false, reason: '没有待应用的 AI 修改' };
const expected = pendingDiff.forFilePath;
const current = state.currentFile && state.currentFile.path;
if (expected == null || current == null || expected !== current) {
return {
ok: false,
reason: '当前文件已切换,原 AI 修改已失效(请重新提交)',
};
}
return { ok: true };
}
function syncBodyFlag() {
// 中间面板:body.dataset.aiDiffOpen = 'true' 时 CSS 插入一列
// 注意:isReadOnly 也要让面板打开 —— AI 普通回复(regions=[])也算"有 AI 内容要展示"
const has = !!pendingDiff && (
pendingDiff.isReadOnly ||
(pendingDiff.regions || []).length > 0
);
document.body.dataset.aiDiffOpen = has ? 'true' : 'false';
}
// 创建面板实例(DOM 必须在 bindMountPoint 之后才能实例化)
let chatPanel = null;
let diffPanel = null;
function bindMountPoint(chatElement, diffElement, splitterElement = null) {
// 3rd-agent fix:重复 bind 要先销毁旧实例,否则旧 listener 残留、
// 旧 _submitting 状态无法被新 controller 感知。
if (chatPanel && typeof chatPanel.destroy === 'function') {
try { chatPanel.destroy(); } catch { /* ignore */ }
}
if (diffPanel && typeof diffPanel.destroy === 'function') {
try { diffPanel.destroy(); } catch { /* ignore */ }
}
chatPanel = new AiChatPanel({
element: chatElement,
onSubmit: handleSubmit,
onCancel: handleCancel,
// 让 panel 内部的过程/错误文案(正在发送 / 已取消 / AI 请求失败 等)
// 走 onNotify → app.js 的 updateStatusAi → statusbar 上的 #status-ai chip。
// 详见 [feedback-ai-tips-into-status-bar]。
onNotify: notify,
});
// 一开始禁用(直到用户打开文件)
chatPanel.syncFileState(!!state.currentFile);
diffPanel = new AiDiffPanel({
element: diffElement,
onApplyRegion: handleApplyRegion,
onApplyAll: handleApplyAll,
onDismiss: handleDismiss,
onSkipRegion: handleSkipRegion,
splitterElement,
});
// 默认无 diff:hidden
diffPanel.setDiff(null);
}
async function handleSubmit({ prompt, requestId }) {
if (!isConfigured()) {
// 未配置:直达设置对话框(焦点定位到 AI 段),让用户能立刻看到缺什么。
// toast 留给 dialog 自己展示,比一条转瞬即逝的提示更直接。
if (typeof onOpenSettings === 'function') {
try {
await onOpenSettings();
} catch {
// dialog 自身挂了不要让用户干等 —— 给个明确的可执行下一步
if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error');
}
}
// 给状态栏 chip 一个明确文案,避免触发 _triggerSubmit 的 'AI 修改失败' 兜底。
return { ok: false, error: AI_ERROR.NOT_CONFIGURED, message: aiErrorMessage(AI_ERROR.NOT_CONFIGURED) };
}
if (!state.currentFile) {
if (typeof toast === 'function') toast('请先打开一个 Markdown 文件', 'warning', 2500);
return { ok: false, error: AI_ERROR.NO_FILE, message: aiErrorMessage(AI_ERROR.NO_FILE) };
}
// P0-1 fix (audit):捕获提交时的文件对象 + 单调 generation。
// await 之后比对:文件已切换 / generation 已递增 → 视为 stale,
// 丢弃本响应(防止 AI 修改错文件 —— 数据丢失风险)。
const myGen = ++requestGeneration;
const myFile = state.currentFile;
const filename = myFile.name || '';
// H4 fix (audit):拿到 prompt 后再读最新内容,避免 stale baseContent。
const contentSnapshot = safeGetContent();
if (contentSnapshot === null) {
return { ok: false, error: AI_ERROR.EDITOR_UNAVAILABLE, message: aiErrorMessage(AI_ERROR.EDITOR_UNAVAILABLE) };
}
// 记录当前请求 id:切文件时调 aiCancel 主动终止,避免 AI 响应
// 回到一个已经不相关的文档(详见 onFileChanged)。
currentRequestId = requestId;
// H2 fix (audit):提交时立刻清掉旧 pendingDiff,避免 awaiting 期间中间面板
// 显示上一次 AI 的陈旧 diff(用户点了"应用此处"以为对的是新请求)。
if (pendingDiff) {
setPendingDiff(null);
}
let result;
try {
result = await api.aiEdit({ prompt, content: contentSnapshot, filename, requestId });
} catch (e) {
currentRequestId = null;
return { ok: false, error: 'IPC_FAILED', message: e && e.message || 'IPC 异常' };
}
// P0-1 fix (audit):await 之后做 stale 检查 —— 注意 currentRequestId 只在
// 我们仍是「最新一次提交」时才清掉,否则会让并发的下一次提交找不到自己
// 的 requestId(onFileChanged / destroy 看 currentRequestId 决定是否取消
// in-flight 请求)。audit M1 fix:currentRequestId 必须在 stale 检查通过后
// 才允许清,且只在我们仍是 currentRequestId 的持有者时清。
if (myGen !== requestGeneration || state.currentFile?.path !== myFile?.path) {
// L2 fix (audit):路径比较替代对象身份。openFile 会基于 reload/mtime 更新
// state.currentFile = { ...state.currentFile, mtimeMs, size }(新对象),
// 旧身份比较会让「同一文件路径、仅 mtime 更新」误判为已切文件,
// 把刚回的 AI 响应丢进 stale 分支。改用路径字符串比较。
if (currentRequestId === requestId) currentRequestId = null;
return {
ok: false,
error: AI_ERROR.GENERATION_STALE,
message: aiErrorMessage(AI_ERROR.GENERATION_STALE),
};
}
// 通过 stale 检查:清掉我们的 requestId 句柄,让 onFileChanged 不再尝试
// 取消这个请求(它马上就要 resolve 了)。
currentRequestId = null;
if (!result || result.ok !== true) {
return { ok: false, error: (result && result.error) || 'UNKNOWN', message: (result && result.message) || 'AI 修改失败' };
}
const aiContent = result.content || '';
const isRaw = result.responseFormat === 'raw';
if (isRaw) {
// AI 没改文档,只是回复一段文字
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: true,
rows: linesToContextRows(aiContent),
regions: [],
forFilePath: myFile && myFile.path, // audit H162:标记这个 diff 属于哪个文件
});
if (chatPanel) chatPanel.setStatus('AI 已返回普通回复,未生成文档修改');
return { ok: true, isRawResponse: true };
}
// 算 diff
const opts = pickDiffOptions(contentSnapshot, aiContent);
let diffResult;
try {
diffResult = api.aiDiffCompute(contentSnapshot, aiContent, opts);
} catch (e) {
return { ok: false, error: 'DIFF_FAILED', message: '计算差异失败:' + (e && e.message || '未知') };
}
if (!diffResult.regions || diffResult.regions.length === 0) {
// AI 返回内容与原文一致(已经过 schema 比较过,但 baseContent 可能在
// 期间被外部改过,所以再次校验)
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: true,
rows: linesToContextRows(aiContent),
regions: [],
forFilePath: myFile && myFile.path,
});
if (chatPanel) chatPanel.setStatus('AI 返回内容与当前文档一致');
return { ok: true, isRawResponse: false };
}
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: false,
rows: diffResult.rows,
regions: diffResult.regions,
forFilePath: myFile && myFile.path,
});
if (chatPanel) {
const text = `AI 已返回,请在中间面板确认(${diffResult.regions.length} 处修改)`;
// AI_STATUS.DIFF:chip 用 accent-soft 高亮,让用户一眼看到"中间面板有 N 处待确认"
// 显式 notify 而非 setStatus —— 后者会被 panel 内部推断为 success(中性 muted),
// 弱化了关键状态的视觉权重。
safeNotify(text, AI_STATUS.DIFF);
}
// H3 fix (audit):diff 引擎 fallback 时(行级 cells 超 MAX_LINE_DIFF_CELLS /
// 词级超 MAX_TOKEN_DIFF_CHARS)会附带 warnings 字段。告知用户「diff 退化为
// 整段替换」或「词级已退化为行级」,避免 UI 上看到「整篇被改」以为是 AI 整篇重写。
if (Array.isArray(diffResult.warnings) && diffResult.warnings.length > 0 && typeof toast === 'function') {
diffResult.warnings.forEach((msg) => toast(msg, 'warning', 5000));
}
return { ok: true, isRawResponse: false };
}
function handleCancel(requestId) {
// 取消 in-flight 请求;++generation 让 handleSubmit 在 await 返回后
// 走 stale 分支(即使主进程 IPC 失败没真取消,也阻止后续 setPendingDiff)。
requestGeneration++;
// L5 fix (audit):清掉 currentRequestId —— 否则 onFileChanged / destroy 看到
// 非空 currentRequestId 会再发一次 cancel(重复 IPC)+ 误以为还有 in-flight 请求。
currentRequestId = null;
if (api.aiCancel) api.aiCancel(requestId);
}
function handleApplyRegion(regionId /*, diff */) {
if (!pendingDiff) return;
// audit fix (Round 13 / Sec-H1):isReadOnly=true 的 diff 没有可应用 region
// (regions:[]),且 nextContent 是 AI 普通文本回复而非文档修改。panel 不会
// 渲染 apply 按钮,但事件委托若被 AI 控制的 [data-action] 元素覆盖(Sec-H1
// 已加 closest('.ai-reply-content') 兜底),仍可能在 controller 这里再次走到
// apply 路径 —— editor.applyUserChange(aiChatReply) 会把打开的笔记替换为
// AI 的聊天文本。二次守一道:readOnly 时直接拒绝,连 region 查询都不跑。
if (pendingDiff.isReadOnly) return;
// audit H162:apply 前确认 pendingDiff 还是当前文件的。
// onFileChanged 在切换文件时会 setPendingDiff(null) —— 但有一种竞态:
// 用户提交 AI → 等响应 → 切到 B 文件(pendingDiff 被清)→ 切回 A 文件
// (onFileChanged 不会再清,因为已经清过了)→ 此时 pendingDiff 是 null,
// 但如果 controller 在切到 B 之前刚生成 pendingDiff 且切回 A 后没刷新,
// 那 pendingDiff 仍存在但属于 A。下面这层 forFilePath 校验兜底。
const check = ensurePendingDiffMatchesCurrentFile();
if (!check.ok) {
if (typeof toast === 'function') toast(check.reason, 'error', 3000);
setPendingDiff(null);
return;
}
const region = pendingDiff.regions.find((r) => r.id === regionId);
if (!region) return;
const current = safeGetContent();
if (current === null) {
if (typeof toast === 'function') toast('无法读取编辑器内容,已停止应用', 'error');
return;
}
let result;
try {
result = api.aiDiffApply(current, region);
} catch (e) {
if (typeof toast === 'function') toast('应用失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):chatPanel status 与 toast 同步 —— 否则面板里
// 还停留在「已应用 N 处修改」/「待确认」的旧状态,用户以为成功了。
if (chatPanel) chatPanel.setStatus('AI 应用失败,可重试');
return;
}
if (!result.ok) {
// 在该 region 上标 conflict,UI 灰掉"应用此处"按钮
pendingDiff = {
...pendingDiff,
regions: pendingDiff.regions.map((r) => r.id === regionId ? { ...r, conflict: result.reason } : r),
};
// audit fix (1.3):冲突时**不修改** lastAppliedContent —— 编辑器内容没变,
// 用户先前若有手动编辑,safeGetContent() 返回的是漂移值,把它写回 lastAppliedContent
// 会让 onContentChanged 跳过本应触发的 recompute。
// pendingDiff.baseContent 是当前 diff 的基线,保持原状即可。
if (diffPanel) diffPanel.setDiff(pendingDiff);
// audit fix (Round 9):同步 chatPanel status 让用户知道 apply 失败。
if (chatPanel) chatPanel.setStatus('该处无法应用,请用「跳过」放弃');
return;
}
// M5 fix (audit):对称 handleApplyAll 的空内容保护 —— applyDiffRegionSafely
// 在极端场景(region 跨边界 + 上下文匹配失败)可能算出 "" 或纯空白。
// applyUserChange('') 会把区域对应行静默清空(数据丢失),必须挡住。
// C4 fix (audit):但 region.oldLines.length > 0 时算「整段删除」是合法语义
// (AI 明确有内容可删),此时 appliedContent 为空是正确的,不能挡。
const appliedContent = result.content;
const isLegitimateDelete = region.oldLines.length > 0;
if (
typeof appliedContent !== 'string'
|| (!isLegitimateDelete && appliedContent.replace(/\s+/g, '').length === 0)
) {
if (typeof toast === 'function') {
toast('AI 返回内容为空,已拒绝应用该处(请用「跳过」放弃该修改)', 'error', 4000);
}
// 把 region 标 conflict 让用户走「跳过」逃生口
pendingDiff = {
...pendingDiff,
regions: pendingDiff.regions.map((r) => r.id === regionId ? { ...r, conflict: 'AI 返回内容为空' } : r),
};
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('AI 返回内容为空,请「跳过」放弃');
return;
}
// 应用:替换编辑器内容(标脏)
try {
editor.applyUserChange(appliedContent);
} catch (e) {
// audit fix (Phase O-M1):IME 合成期间抛 EDITOR_COMPOSING 阻止 apply,
// 提示用户稍后再试(不要显示「写入编辑器失败」误导成磁盘错误)。
if (e && e.message === 'EDITOR_COMPOSING') {
if (typeof toast === 'function') toast('编辑器正在输入中文,请稍后再试', 'warning');
// audit fix (Round 9):同步 chatPanel status —— chip 仍显示
// 「待确认 N 处」会让用户以为还在 diff 模式可用。
if (chatPanel) chatPanel.setStatus('正在输入中文,请稍后再试');
return;
}
if (typeof toast === 'function') toast('写入编辑器失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('写入编辑器失败,可重试');
return;
}
// M5 fix (audit):apply 成功后立即把 pendingDiff.baseContent 推进到 result.content,
// 不要等到下面的 compute 抛错 / 不更新就早返回。baseContent 必须始终反映
// 「编辑器的当前实际内容」—— 后续任何重算 / onFileChanged 比对都依赖它。
// 旧顺序里如果 aiDiffCompute 抛错,编辑器是新内容、pendingDiff.baseContent
// 还是旧内容,用户看到「已应用的修改没体现在剩余 diff 里」的诡异行为。
pendingDiff = { ...pendingDiff, baseContent: result.content };
// preview 模式修复:apply 后立即通知外部刷新 viewer。split 模式下
// scheduleLivePreview 会覆盖(防双触发不致命 —— loadContentLive 幂等)。
if (typeof onAfterApply === 'function') {
try { onAfterApply(); } catch { /* 不让 viewer 渲染失败阻断 apply */ }
}
// 用 AI 改后的 base 重算剩余 diff
let remaining;
try {
// audit A5:与 recomputeDiffFromBase 同理,包 try/catch 防 sync 抛错。
remaining = api.aiDiffCompute(result.content, pendingDiff.nextContent, pickDiffOptions(result.content, pendingDiff.nextContent));
} catch (e) {
if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('重算差异失败');
return;
}
if (!remaining || !remaining.regions || remaining.regions.length === 0) {
// 全部应用完
if (typeof toast === 'function') toast('已应用所有 AI 修改', 'success', 1500);
setPendingDiff(null);
if (chatPanel) chatPanel.setStatus('已应用所有 AI 修改');
} else {
pendingDiff = { ...pendingDiff, baseContent: result.content, ...remaining };
lastAppliedContent = result.content;
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
// H3 fix (audit):partial apply 后 chatPanel status 也要更新(regions 数量变了)
if (chatPanel) {
// AI_STATUS.DIFF:剩余待确认数要靠 chip 高亮 —— 见 handleSubmit 的同款注释
safeNotify(`已应用 1 处修改,还剩 ${remaining.regions.length} 处待确认`, AI_STATUS.DIFF);
}
}
}
function handleApplyAll(/* diff */) {
if (!pendingDiff) return;
// audit fix (Round 13 / Sec-H1):与 handleApplyRegion 同款 —— isReadOnly
// 状态不可能有合法 apply 路径,nextContent 是 AI 普通聊天文本,覆盖编辑器
// 即等于「把笔记销毁换成聊天回复」。详见 handleApplyRegion 同处注释。
if (pendingDiff.isReadOnly) return;
// audit H163:与 handleApplyRegion 同款校验,挡住「跨文件覆盖」数据丢失。
const check = ensurePendingDiffMatchesCurrentFile();
if (!check.ok) {
if (typeof toast === 'function') toast(check.reason, 'error', 3000);
setPendingDiff(null);
return;
}
const next = pendingDiff.nextContent;
// P0 fix:拒绝空内容覆盖 —— AI 返回 '' 时(截断 / 服务异常 / 提示词诱导),
// applyDiffRegionSafely 也会算出"全删"region,"应用全部"就会把用户文档
// 静默替换成空串(这里就是 clearCurrentFile 之外的另一条销毁路径)。
// 校验放在最后一道关卡前:next 必须有非空白字符。
if (typeof next !== 'string' || next.replace(/\s+/g, '').length === 0) {
if (typeof toast === 'function') {
toast('AI 返回内容为空,已拒绝应用(请用「跳过」逐条处理,或重新提交)', 'error', 4000);
}
// 把当前 diff 标成 readOnly、关掉 apply 路径,让用户能继续查看 / skip 单条
pendingDiff = {
...pendingDiff,
isReadOnly: true,
};
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
return;
}
// M4 fix (audit):用户在等待 AI 时可能继续编辑文档。pendingDiff.baseContent
// 是提交时的快照,若当前编辑器内容已不同,「应用全部」会用 nextContent
// 直接覆盖整个文档,**丢掉用户在等待期间做的修改**(数据丢失)。
//
// 审计修复 (Round 11):之前只弹一条 toast 警告但 apply 照常进行 —— 5 秒
// 警告用户根本来不及反应,apply 已完成、用户的修改被静默覆盖。
// 现在 drift 检出后把 pendingDiff 标成 isReadOnly=true(diffPanel 会隐藏
// "应用全部" / "应用此处" 按钮),强制用户「逐条 skip / apply」让决定更精细,
// 或者撤销自己的编辑后重提。最坏情况保留全删路(用户主动重提后 baseContent
// 重新对齐,apply 不再丢失任何东西)。
const currentForApply = safeGetContent();
if (currentForApply !== null && currentForApply !== pendingDiff.baseContent) {
if (typeof toast === 'function') {
toast('你在等待 AI 时已修改过文档,「应用全部」会覆盖未在 diff 内的修改,已切换为逐条确认', 'warning', 4000);
}
if (chatPanel) chatPanel.setStatus('已切换逐条确认模式(你等待时编辑过文档)');
pendingDiff = {
...pendingDiff,
isReadOnly: true,
};
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
return;
}
try {
editor.applyUserChange(next);
} catch (e) {
// audit fix (Phase O-M1):同上,IME 合成期间拒绝 apply。
if (e && e.message === 'EDITOR_COMPOSING') {
if (typeof toast === 'function') toast('编辑器正在输入中文,请稍后再试', 'warning');
// audit fix (Round 9):同步 chatPanel status(与 handleApplyRegion 对齐)。
if (chatPanel) chatPanel.setStatus('正在输入中文,请稍后再试');
return;
}
if (typeof toast === 'function') toast('写入编辑器失败:' + (e && e.message || '未知'), 'error');
// audit fix (Round 9):同步 chatPanel status。
if (chatPanel) chatPanel.setStatus('写入编辑器失败,可重试');
return;
}
// preview 模式修复:apply 后立即通知外部刷新 viewer(见 handleApplyRegion 同处注释)
if (typeof onAfterApply === 'function') {
try { onAfterApply(); } catch { /* 同上 */ }
}
if (typeof toast === 'function') toast('已应用所有 AI 修改', 'success', 1500);
setPendingDiff(null);
if (chatPanel) chatPanel.setStatus('已应用所有 AI 修改');
}
/**
* audit fix:从 diff 列表中丢弃一个 region(一般是 conflict 标了之后用户放弃应用)。
* 旧版本唯一的恢复路径是「dismiss 整个 diff」—— 一旦某个 region 因上下文变更无法
* 安全定位(conflict),其它 region 也跟着没法继续用。这里给出细粒度逃生口:
* - 从 pendingDiff.rows / regions 中过滤掉该 region;
* - 重算剩余 diff(如果 baseContent 因为后续编辑已经漂移,需要基于当前内容重算);
* - 如果没有 region 剩了,就 dismiss。
*
* audit fix (H1-settings / H162):补文件匹配守卫 —— 之前 handleApply* 已经有
* ensurePendingDiffMatchesCurrentFile(),handleSkipRegion 漏了同样的保护:
* 用户提 AI → 等响应 → 切到另一文件 → 旧 diff panel 仍可见(pendingDiff 没被
* 清掉?实际 onFileChanged 会清,但 render 早于清 / 残留 region 仍可见),点
* 「跳过」会改写为别的文件的 regions 数组(甚至把整个 diff 关掉)。和 apply
* 同样会造成「A 的修改写到 B」类数据事故。
*/
function handleSkipRegion(regionId) {
if (!pendingDiff) return;
// 与 handleApply* 一致:先确认 pendingDiff 还属于当前文件
const guard = ensurePendingDiffMatchesCurrentFile();
if (!guard.ok) {
if (typeof toast === 'function') toast(guard.reason, 'warning', 2500);
setPendingDiff(null);
return;
}
const filtered = (pendingDiff.regions || []).filter((r) => r.id !== regionId);
const filteredRows = (pendingDiff.rows || []).filter((row) => row.regionId !== regionId);
// 如果 baseContent 没变,直接用过滤后的 rows;否则重算(保持 diff 与最新编辑器同步)
const current = safeGetContent();
if (current === null) {
if (typeof toast === 'function') toast('无法读取编辑器内容,已停止操作', 'error');
return;
}
let nextRows = filteredRows;
let nextRegions = filtered;
let nextBase = pendingDiff.baseContent;
const skippedRegion = (pendingDiff.regions || []).find((r) => r.id === regionId);
if (current !== pendingDiff.baseContent && pendingDiff.nextContent) {
try {
const recomputed = api.aiDiffCompute(current, pendingDiff.nextContent, pickDiffOptions(current, pendingDiff.nextContent));
if (recomputed && Array.isArray(recomputed.rows) && Array.isArray(recomputed.regions)) {
nextRows = recomputed.rows;
nextRegions = recomputed.regions;
nextBase = current;
// 用户已编辑 → 新 regions 由当前内容 + AI nextContent 重算得出。
// 但「跳过 regionId」是用户明确的意图 —— 简单丢弃 ID 是不够的:
// recomputed.regions 是新生成的 ID,旧的 regionId 在新集合里压根不存在。
// best effort:把 oldLines 文本与被跳过的 region 相同的 region 一并剔除。
// 边界:用户编辑使 skippedRegion.oldLines 在 current 里已经不存在
// (例如用户直接把那几行删了),recompute 也不会再生成对应 region,filter 是 no-op。
if (skippedRegion && Array.isArray(skippedRegion.oldLines)) {
const skippedKey = skippedRegion.oldLines.join('\n');
// 1) 把「oldLines 与被跳过 region 相同」的新 region 剔除
nextRegions = nextRegions.filter((r) => !(Array.isArray(r.oldLines) && r.oldLines.join('\n') === skippedKey));
// 2) 把指向被跳过 region 的 row 剔除(如果旧 ID 还残留在 row 数组里)
nextRows = nextRows.filter((row) => row.regionId !== skippedRegion.id);
}
} else {
// 审计修复 (Round 11 P1-4):recomputed 无效(null / 缺 rows/regions)也必须 bail。
// catch 分支已有 return,但 try 内部"recompute 走通但返回无效 shape"会落到
// 下面 if (!nextRegions...) 用 stale baseContent + filtered(旧 regions)继续
// 渲染,diff panel 看着正常但实际没重算 → 用户以为重新算了实际还是旧的。
// 失败就放弃本次 skip,不让 diff panel 进入误导态。
if (typeof toast === 'function') toast('重算差异失败:返回结果无效,已跳过本次跳过操作', 'error');
return;
}
} catch (e) {
if (typeof toast === 'function') toast('重算差异失败:' + (e && e.message || '未知'), 'error');
return;
}
}
if (!nextRegions || nextRegions.length === 0) {
// 跳过之后没 region 了,关掉 diff 面板
if (typeof toast === 'function') toast('已跳过该修改', 'success', 1500);
setPendingDiff(null);
if (chatPanel) chatPanel.setStatus('已跳过该修改,无更多待确认');
return;
}
pendingDiff = {
...pendingDiff,
baseContent: nextBase,
rows: nextRows,
regions: nextRegions,
};
lastAppliedContent = current;
if (diffPanel) diffPanel.setDiff(pendingDiff);
syncBodyFlag();
if (chatPanel) {
// AI_STATUS.DIFF:skip 一处后剩余待确认数要靠 chip 高亮
safeNotify(`已跳过 1 处修改,还剩 ${nextRegions.length} 处待确认`, AI_STATUS.DIFF);
}
}
function handleDismiss() {
setPendingDiff(null);
// dismiss 把 chip 收回到 idle —— 显式 notify 而非 setStatus(''):
// setStatus('') 会被 panel 内部推断为 'success'(保持 muted 文字),
// 我们要的是真正清空(chip 文字回到 "AI"),用 AI_STATUS.IDLE 类型最准。
safeNotify('', AI_STATUS.IDLE);
}
/**
* 工具栏按钮 / 快捷键调:切 AI 输入面板
* @returns {boolean} true 表示打开了面板
*/
function togglePanel() {
if (!chatPanel) return false;
if (chatPanel.isOpen()) {
chatPanel.close();
return false;
}
// audit fix (Phase L3-AI 4):AI dock 与设置对话框互斥。
// openSettingsDialog 已经在打开时主动 close dock 并 close 后还原,但用户在
// 设置打开期间手动点 AI 按钮(侧栏 btn-ai / 状态栏 chip / 快捷键)会让
// chatPanel.open() 把 dock 显示出来,视觉上从 modal 半透明背景后透出,与
// 设置对话框重叠 → 误点率高。这里守一道:设置打开时拒绝开 dock 并 toast。
if (typeof isSettingsOpen === 'function' && isSettingsOpen()) {
if (typeof toast === 'function') {
toast('设置对话框打开时无法打开 AI 面板,请先关闭设置', 'info');
}
return false;
}
if (!isConfigured()) {
// 未配置:直达设置对话框(焦点定位到 AI 段),让用户能立刻补 Base URL / Key / Model。
if (typeof onOpenSettings === 'function') {
try {
// onOpenSettings 可能是 async:catch 同步 throw,.catch 接住 Promise 拒绝
const r = onOpenSettings();
if (r && typeof r.catch === 'function') r.catch(() => {
if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error');
});
} catch {
if (typeof toast === 'function') toast('无法自动打开设置对话框,请从菜单 / 工具栏手动打开', 'error');
}
}
return false;
}
chatPanel.open();
return true;
}
/**
* 全部关闭(chat dock + diff 面板)。
* diff 跟随 chat 一起关:让"AI 修改"按钮成为唯一的开关入口。
*/
function closeAll() {
if (chatPanel) chatPanel.close();
// 关 dock 时也清掉防抖 timer,避免 pendingDiff=null 之后又触发 recompute
if (recomputeTimer) { clearTimeout(recomputeTimer); recomputeTimer = null; }
requestGeneration++;
// L5 fix (audit):关 dock 也清掉 currentRequestId + 发一次 cancel。
// 否则再 open 时 controller 以为自己还有 in-flight 请求,新提交会被
// onFileChanged/destroy 看到 currentRequestId 还在,重复发 cancel。
if (currentRequestId && chatPanel && typeof chatPanel.cancelRequest === 'function') {
try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ }
}
currentRequestId = null;
setPendingDiff(null);
if (chatPanel && typeof chatPanel.reset === 'function') {
// 3rd-agent fix:清掉 _submitting / 输入框残留,避免下次 open 时 input 永久 disabled
chatPanel.reset();
}
}
/**
* 文件切换时同步:清掉 pendingDiff、把新内容当基线
* 同时取消 in-flight 的 AI 请求 —— 否则响应回来时 contentSnapshot 还是
* 上一个文件的内容,会让面板把不相关的 diff 渲染出来。
*/
function onFileChanged(newFile) {
// 取消进行中的 AI 请求(chatPanel 知道自己的 _currentRequestId)
if (chatPanel && currentRequestId) {
chatPanel.cancelRequest(currentRequestId);
currentRequestId = null;
}
// P0-1 fix (audit):递增 generation 让 in-flight handleSubmit 失败,
// 并清掉任何挂起的 debounced recompute(新文件的内容基线不再准确)
if (recomputeTimer) { clearTimeout(recomputeTimer); recomputeTimer = null; }
requestGeneration++;
// 切换文件时放弃当前 diff(避免误应用)
setPendingDiff(null);
if (chatPanel) {
chatPanel.syncFileState(!!newFile);
// 3rd-agent fix:reset 比单独清 status/error 更彻底 —— 也会把
// _submitting=false + input.disabled=false,避免切文件后输入框永久禁用
chatPanel.reset();
}
}
/**
* 编辑器内容变化时:重算 diff(让用户看到"已应用部分还剩多少")
* P1-3 fix (audit):100ms 防抖 —— 连续 keystroke 只跑一次 DP
* (pickDiffOptions 把 tokenDiff 限制在 ≤80k / ≤120k 行乘积,但每个
* keystroke 仍触发同步 DP 会卡顿)。
*/
function onContentChanged() {
if (!pendingDiff) return;
if (recomputeTimer) clearTimeout(recomputeTimer);
recomputeTimer = setTimeout(() => {
recomputeTimer = null;
recomputeDiffFromBase();
}, 100);
}
/**
* 销毁 controller:撤销所有 panel listener、清掉防抖 timer、取消 in-flight 请求。
* 用于页面卸载 / HMR 重置 —— 避免旧实例的 click 监听器 + debounced setTimeout
* 在新 controller 实例运行时继续触发(MutationObserver 在 src/app.js 的 pagehide
* 已用类似清理路径)。
*/
function destroy() {
if (recomputeTimer) {
clearTimeout(recomputeTimer);
recomputeTimer = null;
}
// 递增 generation 让 in-flight handleSubmit 自然废弃(不再写 pendingDiff)
requestGeneration++;
if (chatPanel && typeof chatPanel.cancelRequest === 'function' && currentRequestId) {
try { chatPanel.cancelRequest(currentRequestId); } catch { /* ignore */ }
currentRequestId = null;
}
if (chatPanel && typeof chatPanel.destroy === 'function') {
try { chatPanel.destroy(); } catch { /* ignore */ }
}
if (diffPanel && typeof diffPanel.destroy === 'function') {
try { diffPanel.destroy(); } catch { /* ignore */ }
}
chatPanel = null;
diffPanel = null;
pendingDiff = null;
lastAppliedContent = null;
setPendingDiff(null);
}
return {
bindMountPoint,
togglePanel,
closeAll,
destroy,
isChatOpen: () => isChatOpen(),
hasPendingDiff: () => !!pendingDiff,
onFileChanged,
onContentChanged,
};
}

268
src/ai/ai-diff-panel.js Normal file
View File

@@ -0,0 +1,268 @@
// AI 修改差异预览面板
// ============================================================================
//
// 渲染 AI 改稿前后的行级 + 词级 diff。用户可以:
// - 点 "应用全部修改":把整篇 nextContent 替换进编辑器
// - 点每个 region 的 "应用此处":把单个 region 的 newLines 替换进编辑器
// - 点 "×":丢弃 AI 修改,关掉中间面板
//
// 与 ai-controller 协作:
// 组件本身只负责渲染 + 转发按钮事件;所有"修改编辑器"的操作都通过
// 传进来的 onApply* 回调,由 controller 决定怎么改编辑器、重算 diff。
//
// 数据流:
// diff = { rows, regions, isReadOnly, baseContent, nextContent }
// - rows 已经是扁平化的行序列(含 context / removed / added),按 regionId 分组
// - regions 是 [{ id, oldLines, newLines, conflict? }],每个对应一个"修改块"
// - isReadOnly = true 表示 AI 给了纯文本回复(不是文档修改),整块只读展示
// ============================================================================
import { escapeHtml, safeRenderMarkdown } from '../utils.js';
/**
* 把扁平 rows 拆成 context 行 + region 块,渲染时按这个结构走。
* @param {Array<{id:string, regionId?:string, type:string}>} rows
* @returns {Array<{type:'context', row:any} | {type:'region', regionId:string, rows:any[]}>}
*/
function buildBlocks(rows) {
const blocks = [];
let index = 0;
while (index < rows.length) {
const row = rows[index];
if (!row.regionId) {
blocks.push({ type: 'context', row });
index += 1;
continue;
}
const regionId = row.regionId;
const regionRows = [];
while (index < rows.length && rows[index].regionId === regionId) {
regionRows.push(rows[index]);
index += 1;
}
blocks.push({ type: 'region', regionId, rows: regionRows });
}
return blocks;
}
/**
* 渲染一行 diff。
* - removed 行用红色 - 前缀
* - added 行用绿色 + 前缀
* - context 行用空格前缀(与 git diff 一致)
* - token 粒度的 inline diff(equal/removed/added)会进一步切分
*/
function renderDiffRow(row, hideLineNumbers) {
const lineNumber = row.type === 'removed' ? row.oldLineNumber : row.newLineNumber;
const prefix = row.type === 'added' ? '+' : row.type === 'removed' ? '-' : ' ';
const segmentsHtml = (row.segments || [])
.map((seg) => {
const text = seg.text || ' ';
return `<span class="ai-diff-segment ai-diff-segment-${seg.type}">${escapeHtml(text)}</span>`;
})
.join('');
const numberHtml = hideLineNumbers
? ''
: `<span class="ai-diff-line-number" aria-hidden="true">${Number.isFinite(lineNumber) ? lineNumber : ''}</span>`
+ `<span class="ai-diff-line-prefix" aria-hidden="true">${prefix}</span>`;
return `
<div class="ai-diff-line ai-diff-line-${row.type}${hideLineNumbers ? ' ai-diff-line-plain' : ''}" role="row">
${numberHtml}
<span class="ai-diff-line-text" role="gridcell">${segmentsHtml}</span>
</div>
`;
}
export class AiDiffPanel {
/**
* @param {object} options
* @param {HTMLElement} options.element - 容器 div
* @param {(diff:any) => void} options.onApplyRegion - 单 region 应用
* @param {(diff:any) => void} options.onApplyAll - 全部应用
* @param {(diff:any) => void} options.onDismiss - 关闭预览
* @param {(regionId:string, diff:any) => void} [options.onSkipRegion] - 跳过单个 region
* (一般用于 conflict 区域 —— 给用户提供细粒度逃生口,避免一次冲突就要放弃整篇 AI 修改)
*/
constructor({ element, onApplyRegion, onApplyAll, onDismiss, onSkipRegion, splitterElement = null }) {
this.element = element;
// AI 拖动 splitter 元素(与 .app-ai 联动显隐)。
// 设了 splitterElement 后,setDiff 会自动同步它的 hidden。
this.splitterElement = splitterElement;
this.onApplyRegion = onApplyRegion || (() => {});
this.onApplyAll = onApplyAll || (() => {});
this.onDismiss = onDismiss || (() => {});
this.onSkipRegion = onSkipRegion || null;
this._diff = null;
}
/**
* 渲染/更新 diff。diff = null → 显示空状态。
* @param {object|null} diff
*/
setDiff(diff) {
this._diff = diff;
// 每次 setDiff 之前先清掉上一轮的 click 监听器,否则重复 setDiff 会泄漏。
if (this._cleanup) this._cleanup();
this._cleanup = null;
if (!diff) {
this.element.hidden = true;
// 同步把 AI 拖动 splitter 隐藏:避免面板关后 splitter 仍可见占用 grid 列
if (this.splitterElement) this.splitterElement.hidden = true;
this.element.replaceChildren();
return;
}
this.element.hidden = false;
if (this.splitterElement) this.splitterElement.hidden = false;
// C4+H6 fix (audit):_renderHtml / innerHTML 抛错会让 setDiff 中断,
// element 处于 hidden=false 但内部为空 / 半渲染状态,用户看到一个
// 灰板。这里包 try/catch,失败时降级为错误提示 + 保留 dismiss 按钮。
try {
this.element.innerHTML = this._renderHtml(diff);
this._bindEvents(diff);
} catch (e) {
console.error('[ai-diff-panel] setDiff failed:', e);
// M4 fix (audit):兜底再显式 unhide —— 理论上 line 115-116 已设置,
// 但若上一次的 setDiff 走的是 null 分支把 element + splitterElement 都 hidden 了,
// 本轮进入 try 前 line 115-116 一定执行;但若 _renderHtml 内部有同步逻辑
// 改了 dataset / 触发了 reflow(极端情况),catch 进来时面板可能仍处于 hidden。
// 显式再设一次保证失败降级 UI 一定可见、用户能点 dismiss 退出。
this.element.hidden = false;
if (this.splitterElement) this.splitterElement.hidden = false;
this.element.innerHTML = `
<div class="ai-diff-header">
<div>
<h2 class="ai-diff-title">AI 修改预览</h2>
<p class="ai-diff-summary">渲染失败:${escapeHtml(e && e.message || '未知错误')}</p>
</div>
<div class="ai-diff-actions">
<button class="ai-diff-dismiss" type="button" data-action="dismiss" aria-label="关闭 AI 修改预览">×</button>
</div>
</div>
<div class="ai-diff-empty">Diff 渲染异常,请关闭后重试</div>
`;
// 只绑定 dismiss —— 让用户能关掉这个面板
const onClick = (ev) => {
const t = ev.target.closest('[data-action]');
if (t && t.dataset.action === 'dismiss') this.onDismiss(diff);
};
this.element.addEventListener('click', onClick);
this._cleanup = () => this.element.removeEventListener('click', onClick);
}
}
_renderHtml(diff) {
const isReadOnly = !!diff.isReadOnly;
const regions = Array.isArray(diff.regions) ? diff.regions : [];
const regionsById = new Map(regions.map((r) => [r.id, r]));
const hasApplicableDiff = !isReadOnly && regions.length > 0;
const summary = isReadOnly
? 'AI 普通回复,仅供查看'
: hasApplicableDiff ? `${regions.length} 处待确认修改` : 'AI 返回内容与当前文档一致';
const headerActions = hasApplicableDiff
? `<button class="ai-diff-apply-all" type="button" data-action="apply-all">应用全部修改</button>`
: '';
const header = `
<div class="ai-diff-header">
<div>
<h2 class="ai-diff-title">AI 修改预览</h2>
<p class="ai-diff-summary">${escapeHtml(summary)}</p>
</div>
<div class="ai-diff-actions">
${headerActions}
<button class="ai-diff-dismiss" type="button" data-action="dismiss" aria-label="关闭 AI 修改预览">×</button>
</div>
</div>
`;
const body = this._renderBody(diff, regionsById);
return header + body;
}
_renderBody(diff, regionsById) {
if (diff.isReadOnly) {
// 普通文本回复:整块当 markdown 渲染或纯文本展示。
// 这里走 DOMPurify(preload 已暴露 window.api.renderMarkdown)保持与 viewer 一致。
// 没设依赖时退化为 <pre> 纯文本(safeRenderMarkdown 集中处理)。
const html = safeRenderMarkdown(diff.nextContent || '');
return `<div class="ai-reply-content markdown-body" role="region" aria-label="AI 普通回复">${html}</div>`;
}
const blocks = buildBlocks(diff.rows || []);
if (blocks.length === 0) {
return `<div class="ai-diff-empty">AI 返回了空内容</div>`;
}
const parts = blocks.map((block) => {
if (block.type === 'context') {
return renderDiffRow(block.row, false);
}
const region = regionsById.get(block.regionId);
const hasSkip = this.onSkipRegion && region && region.conflict;
const conflict = region && region.conflict
? `<div class="ai-diff-conflict">${escapeHtml(region.conflict)}</div>`
: '';
// conflict 时除了 disabled "应用此处",再多给一个 "跳过此处" 按钮:
// 用户放弃这一处但保留其它 region 的应用机会(之前只能 dismiss 整篇 diff)。
const applyBtn = !region
? ''
: `<button class="ai-diff-region-apply" type="button" data-action="apply-region" data-region-id="${escapeHtml(block.regionId)}"${region.conflict ? ' disabled' : ''}>应用此处</button>`;
const skipBtn = hasSkip
? `<button class="ai-diff-region-skip" type="button" data-action="skip-region" data-region-id="${escapeHtml(block.regionId)}" aria-label="跳过该修改区域,保留其它修改">跳过此处</button>`
: '';
const regionHeader = !region
? `<div class="ai-diff-conflict">无法定位该修改区域</div>`
: `<div class="ai-diff-region-actions">${applyBtn}${skipBtn}</div>`;
const rowsHtml = block.rows.map((r) => renderDiffRow(r, false)).join('');
return `
<section class="ai-diff-region" data-region-id="${escapeHtml(block.regionId)}">
<header class="ai-diff-region-header">${regionHeader}${conflict}</header>
<div class="ai-diff-region-body">${rowsHtml}</div>
</section>
`;
}).join('');
return `<div class="ai-diff-fulltext" role="table" aria-label="AI 修改全文对比">${parts}</div>`;
}
_bindEvents(diff) {
const onClick = (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
// audit fix (Round 13 / Sec-H1):拒绝 AI 控制内容里的 [data-action]。
// 威胁:isReadOnly=true(AI 普通文本回复)时,整块内容由 AI 控制的 markdown
// 渲染得到,DOMPurify 默认不过滤 data-* / <span>/<button> —— 攻击者(或
// 通过 prompt injection 诱导)可在回复里塞
// `<span data-action="apply-all" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647"></span>`
// 做成全屏透明覆盖层;用户的下一次任意位置点击会沿事件冒泡命中
// closest('[data-action]') → apply-all → editor.applyUserChange(nextContent)
// —— 静默把打开的笔记替换为 AI 的聊天回复(数据销毁)。
// 修复策略:信任域分离 —— read-only 走 markdown-body 这条信任链,事件委托
// 只接受 panel 自身渲染的「应用全部 / 应用此处 / 跳过 / 关闭」按钮。其他按钮
// 路径下,AI 内容里的 [data-action] 同样应被忽略(防御未来新按钮路径),用
// 「目标在 .ai-reply-content 子树内即忽略」统一兜底。
if (target.closest && target.closest('.ai-reply-content')) return;
const action = target.dataset.action;
if (action === 'dismiss') {
this.onDismiss(diff);
} else if (action === 'apply-all') {
this.onApplyAll(diff);
} else if (action === 'apply-region') {
const regionId = target.dataset.regionId;
if (regionId) this.onApplyRegion(regionId, diff);
} else if (action === 'skip-region') {
// 仅在 controller 给了 onSkipRegion 时才会渲染这个按钮;防御性再判一次
const regionId = target.dataset.regionId;
if (regionId && this.onSkipRegion) this.onSkipRegion(regionId, diff);
}
};
this.element.addEventListener('click', onClick);
this._cleanup = () => this.element.removeEventListener('click', onClick);
}
destroy() {
if (this._cleanup) this._cleanup();
this._cleanup = null;
}
}

72
src/ai/ai-status.js Normal file
View File

@@ -0,0 +1,72 @@
// src/ai/ai-status.js —— AI 链路状态类型 / 错误码常量
//
// 把散落在 ai-controller / ai-chat-panel / ai-diff-panel 里的字面量集中
// 单一事实源;状态类型与 chat panel 的 notify / data-ai-state / dataset.status
// 三处渲染点保持一致。
//
// 错误码统一走 shared/ai-errors.js(CJS,main / preload / renderer 三处共用)。
// renderer 是 ESM 不能直接 require CJS,所以 preload 经 contextBridge 暴露
// window.api.aiErrors.AI_ERROR;本文件在模块顶层取这个值。单测(jsdom 无 preload)
// 走下方本地副本 —— 副本与 shared/ai-errors.js 同字面量,新增错误码时两边一起改,
// 由 tests/unit/ai-errors.test.js 校验两边一致。
/**
* AI 状态类型 —— renderer 端所有状态渲染都引用这里。
* 命名与 [data-ai-state] dataset 属性一一对应(见 ai-diff-panel / ai-chat-panel)。
*/
export const AI_STATUS = Object.freeze({
IDLE: 'idle',
RUNNING: 'running',
CANCELLED: 'cancelled',
ERROR: 'error',
SUCCESS: 'success',
DIFF: 'diff',
});
/**
* AI 错误码 —— 来自 shared/ai-errors.js#AI_ERROR。renderer 在模块顶层直接
* window.api?.aiErrors?.AI_ERROR 取值;取不到时(jsdom 单测 / preload 注入失败)
* 退化到下方本地副本。
*
* audit fix (CQ-MED-7):之前 renderer 各自维护一份字面量注释提醒"必须与 main/ai.js
* 同名常量一致"——实际曾漂移(renderer 旧版 ERR_TIMEOUT='ERR_TIMEOUT' 与 main 的
* 'AI_TIMEOUT' 对不上,aiErrorMessage switch 永不命中、永远走 fallback 'AI 修改失败')。
* 现在共享同一文件,注释提醒的"靠人同步"变成"靠文件同步"。
*/
const SHARED_AI_ERROR = (typeof window !== 'undefined'
&& window.api?.aiErrors?.AI_ERROR) || null;
const LOCAL_AI_ERROR_FALLBACK = Object.freeze({
NOT_CONFIGURED: 'AI_NOT_CONFIGURED',
NO_FILE: 'NO_FILE',
EDITOR_UNAVAILABLE: 'EDITOR_UNAVAILABLE',
IPC_FAILED: 'IPC_FAILED',
GENERATION_STALE: 'GENERATION_STALE',
FILE_CHANGED: 'FILE_CHANGED',
ERR_TIMEOUT: 'AI_TIMEOUT',
ERR_PROVIDER: 'AI_PROVIDER_ERROR',
ERR_FORMAT: 'AI_BAD_RESPONSE',
ERR_CANCELLED: 'AI_CANCELLED',
});
export const AI_ERROR = SHARED_AI_ERROR || LOCAL_AI_ERROR_FALLBACK;
/**
* 把 AI 错误码映射成中文消息(用于 status bar chip / toast)。
* 单一映射点,避免各组件各自拼字符串。
*/
export function aiErrorMessage(code, fallback) {
switch (code) {
case AI_ERROR.NOT_CONFIGURED: return 'AI 未配置,已打开设置';
case AI_ERROR.NO_FILE: return '请先打开 Markdown 文件';
case AI_ERROR.EDITOR_UNAVAILABLE: return '无法读取编辑器内容,已取消本次 AI 请求';
case AI_ERROR.IPC_FAILED: return 'AI 通信异常';
case AI_ERROR.GENERATION_STALE: return '文件已切换,旧 AI 响应已丢弃';
case AI_ERROR.FILE_CHANGED: return '文件已被外部修改';
case AI_ERROR.ERR_TIMEOUT: return 'AI 请求超时';
case AI_ERROR.ERR_PROVIDER: return 'AI 服务返回异常';
case AI_ERROR.ERR_FORMAT: return 'AI 响应格式无法解析';
case AI_ERROR.ERR_CANCELLED: return '已取消 AI 请求';
default: return fallback || 'AI 修改失败';
}
}

3959
src/app.js Normal file

File diff suppressed because it is too large Load Diff

118
src/component.js Normal file
View File

@@ -0,0 +1,118 @@
// 可销毁组件的样板(AbortController mixin)
//
// 之前大部分 class(FileList / MarkdownViewer / Toolbar / EditorToolbar / ContextMenu)
// 在构造函数里直接 `element.addEventListener(...)` 挂 listener,却没有统一的
// dispose 路径。结果:
// - 单例组件销毁时无法 unbind,listener 持有闭包 → GC 不掉
// - 测试 / HMR 场景下,组件替换时旧 listener 仍触发回调
// - listener 多起来后还得手动写 `this._abort()` / `removeEventListener`,无样板
//
// 这个文件提供一个一次性 AbortController + 在 signal 上挂 listener 的薄封装:
//
// class FileList {
// constructor(opts) {
// const disposable = createDisposable(this);
// this.disposable = disposable; // 暴露 signal(必要时给外部用)
// this.addEventListener = disposable.addEventListener;
// this.dispose = disposable.dispose;
// element.addEventListener('click', (e) => ..., { signal: disposable.signal });
// }
// }
//
// 调用 `instance.dispose()` 一次性 abort 所有挂在这个 signal 上的 listener。
/**
* @typedef {object} Disposable
* @property {AbortSignal} signal - 给 addEventListener({ signal }) 用
* @property {typeof HTMLElement.prototype.addEventListener} addEventListener
* 包装版 addEventListener,自动挂 signal。等价于手动传 { signal: this.signal }。
* @property {(fn: () => void) => void} addAbort - 注册 abort 时一次性执行的清理逻辑
* @property {() => void} dispose - 触发 abort + 标记 disposed;幂等
* @property {boolean} isDisposed - 当前是否已 dispose
*/
/**
* 给目标对象(通常是一个 class 实例)添加可销毁 listener 能力。
* 注意:本函数不会修改 target 的原型链 —— 只挂你需要的属性。调用方决定挂哪些。
*
* @param {object} [target] - 用来挂 mixin 属性的对象(默认返回独立对象)
* @returns {Disposable}
*/
export function createDisposable(target = {}) {
const controller = new AbortController();
let disposed = false;
/**
* 包装 addEventListener:默认注入当前 signal,省略 opts 时等价于
* `{ signal: controller.signal, capture: false, passive: false }`。
* 注意:opts 里有 signal 时以调用方为准(允许覆盖,例如与其它组件共享 signal)。
*/
function addEventListener(elementOrTarget, type, handler, opts) {
if (!elementOrTarget || typeof elementOrTarget.addEventListener !== 'function') {
throw new TypeError('addEventListener: target 不支持 addEventListener');
}
const merged = mergeOptsWithSignal(opts, controller.signal);
elementOrTarget.addEventListener(type, handler, merged);
}
function addAbort(fn) {
if (typeof fn !== 'function') return;
if (controller.signal.aborted) {
// 已经 abort 了,立即同步执行(与 AbortSignal 的「后注册的 abort listener 同步触发」语义一致)
try { fn(); } catch { /* ignore */ }
return;
}
controller.signal.addEventListener('abort', () => {
try { fn(); } catch { /* ignore */ }
}, { once: true });
}
function dispose() {
if (disposed) return;
disposed = true;
controller.abort();
}
target.signal = controller.signal;
target.addEventListener = addEventListener;
target.addAbort = addAbort;
target.dispose = dispose;
// audit fix (renderer-M12):原本无脑用 Object.defineProperty 覆盖 isDisposed,
// 子类如果有自己的 isDisposed getter/setter(如 lifecycle tracking)会被悄悄
// 顶掉 —— mixin 的不变量没保证。先看 target 自身是否已声明 isDisposed,
// 有就让宿主定义胜出(已有 own / 显式 prototype 定义都跳);只在完全没
// 这个属性时才注入 mixin 的 getter。
if (!Object.prototype.hasOwnProperty.call(target, 'isDisposed')
&& !Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'isDisposed')) {
Object.defineProperty(target, 'isDisposed', {
get() { return disposed; },
enumerable: true,
});
}
return /** @type {Disposable} */ (target);
}
/**
* 把 signal 合并到 opts 里。
* - opts === undefined → { signal }
* - opts 已有 signal → 不动(调用方说了算)
* - opts 是 AddEventListenerOptions 对象(boolean 是 capture)→ 扩展为对象
*
* @param {AddEventListenerOptions|boolean|undefined} opts
* @param {AbortSignal} signal
* @returns {AddEventListenerOptions|boolean}
*/
function mergeOptsWithSignal(opts, signal) {
if (opts === undefined || opts === null) {
return { signal };
}
if (typeof opts === 'boolean') {
// 旧 API:opts = useCapture。signal 在 capture 阶段拦截意义不大,但语义上没问题。
return { capture: opts, signal };
}
if (typeof opts === 'object' && 'signal' in opts) {
return opts;
}
return { ...opts, signal };
}

127
src/confirm-dialog.js Normal file
View File

@@ -0,0 +1,127 @@
// 通用确认对话框
// 复用 .modal-overlay / .modal / .btn-primary / .btn-secondary 样式(src/styles.css)。
//
// 用于:
// - 切换文件前的「未保存改动」提示
// - 外部修改冲突时的「保留我的 / 使用外部」选择
// - 未来扩展(删除 / 重命名等)
//
// 返回 Promise resolve 一个按钮 value(默认 'cancel' / 'confirm')。
// 用户按 Esc 或点击背景 → resolve('cancel')。
//
// 骨架来自 src/modal.js;这里只关心「标题 / 正文 / 按钮列表」三件事。
import { escapeHtml, escapeAttr } from './utils.js';
import { mountModal } from './modal.js';
// audit fix (Phase L2 #7):modal 被另一个对话框占位时给用户 toast,避免
// 重要冲突(未保存改动 / 外部修改)静默 cancel 后用户误以为已生效。
import { showToast } from './feedback.js';
/**
* 预置图标 SVG(与设置对话框 .settings-section-icon 同语言:24×24 viewBox,
* stroke-based, 1.8px stroke-width)。调用方传 icon 名字即可,不必内联 SVG。
*
* - warning:警告三角 —— 未保存改动 / 放弃前提醒
* - danger:圆形 × —— 删除 / 不可逆操作
* - conflict:双向箭头 —— 外部修改冲突
* - info:圆形 i —— 一般信息提示
*/
const CONFIRM_ICONS = {
warning: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3L2 21h20L12 3z"/>
<line x1="12" y1="10" x2="12" y2="14"/>
<circle cx="12" cy="17.5" r="0.5" fill="currentColor" stroke="none"/>
</svg>`,
danger: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<line x1="8" y1="8" x2="16" y2="16"/>
<line x1="16" y1="8" x2="8" y2="16"/>
</svg>`,
conflict: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<polyline points="16 10 16 6 12 6"/>
<polyline points="8 14 8 18 12 18"/>
<line x1="16" y1="6" x2="18" y2="8"/>
<line x1="8" y1="18" x2="6" y2="16"/>
</svg>`,
info: `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<circle cx="12" cy="7.5" r="0.5" fill="currentColor" stroke="none"/>
</svg>`,
};
/**
* 显示一个确认对话框
* @param {object} options
* @param {string} options.title - 标题
* @param {string} options.message - 正文(支持简单 HTML,会被 escape)
* @param {Array<{value:string,label:string,variant?:'primary'|'secondary'|'danger'}>} options.buttons - 按钮列表;最后一个按钮 = 默认焦点
* @param {'warning'|'danger'|'conflict'|'info'|string} [options.icon] - 预置图标名或自定义 SVG 字符串;不传则不显示图标
* @returns {Promise<string|null>} 被点击按钮的 value;用户取消(Esc / 点击背景 / 关闭按钮)时返回 'cancel';
* modal 已被另一个对话框占用(无法打开)时返回 null(同时弹 toast 提示用户)
*/
// 按钮 variant 白名单 —— 防 audit A10:调用方若误传 'foo' 等非法值,
// 之前会得到 `btn btn-foo`,CSS 没这个类,按钮变成无样式的纯文本。
const BUTTON_VARIANTS = new Set(['primary', 'secondary', 'danger']);
export function showConfirmDialog({ title, message, buttons = [], icon }) {
return new Promise((resolve) => {
const footerHtml = buttons.map((b) => {
const variant = BUTTON_VARIANTS.has(b.variant)
? b.variant
: (b.value === 'cancel' ? 'secondary' : 'primary');
return `<button class="btn btn-${variant}" type="button" data-action="${escapeAttr(b.value)}">${escapeHtml(b.label)}</button>`;
}).join('');
// 图标区域:预置名 → 取内置 SVG;自定义 SVG 字符串 → 直接用;无 → 不渲染。
// icon 颜色由 CSS 的 .confirm-icon-wrap 变体控制(warning 走 --warning, danger 走 --danger)。
const iconSvg = icon ? (CONFIRM_ICONS[icon] || icon) : '';
// iconVariant 用于 CSS 着色:预置名直接传,自定义 SVG 默认走 'info'。
const iconVariant = icon ? (CONFIRM_ICONS[icon] ? icon : 'info') : '';
const iconHtml = iconSvg
? `<div class="confirm-icon-wrap confirm-icon-${escapeAttr(iconVariant)}">${iconSvg}</div>`
: '';
const bodyHtml = `${iconHtml}<p class="confirm-message">${escapeHtml(message)}</p>`;
const modal = mountModal({
title,
body: bodyHtml,
footer: footerHtml,
onAction: () => {
// 任一按钮按下:返回 'close' 由 mountModal 统一负责关闭
// —— 自己不调 modal.close,避免与 modal.js 的 close(action) 重复触发
return 'close';
},
onBackdropClose: 'cancel',
});
// 给 confirm 类型 modal 加专属 class —— CSS 用来收窄宽度(confirm 文案短,
// 不需要 settings dialog 的 560px)、调整 body padding 与 icon 布局。
if (modal) modal.modal.classList.add('modal--confirm');
if (!modal) {
// P1-3:modal 已被另一个对话框占用(罕见但会发生,比如用户在设置对话框
// 开着时按 Ctrl+S)。旧实现降级到 window.confirm —— 但 confirm 是浏览器
// 原生模态,会与现有 custom modal 同屏出现:用户先关掉原生框,custom
// modal 还浮在上面,体验割裂。
// 这里改为「静默 cancel」:把决定权交还给调用方,由它决定是排队重试还是
// 干脆放弃(settings dialog 的 preUpdateHook 就是这么用的)。
//
// audit fix (Phase L2 #7):但「重要冲突」场景(未保存改动、外部修改冲突)
// 静默 cancel 等于丢数据 —— 用户看到「啥都没发生」,可能误以为已保存。
// 给一个 toast 提示用户「先关闭占用方」,与 settings-dialog 的行为对齐。
//
// fix(audit 2026-08):原版与「用户主动取消」都返回 'cancel',导致调用方
// (app.js 外部修改冲突)把 modal-occupied 误识别为用户主动选择「暂不处理」
// → 静默推进 lastSavedMtimeMs → 后续外部修改被吞。改为返回 null,让调用方
// 能区分「对话框没出来」与「用户主动取消」。
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
resolve(null);
return;
}
modal.setOnClose(resolve);
});
}

285
src/context-menu.js Normal file
View File

@@ -0,0 +1,285 @@
// 自定义右键菜单
//
// 用于 markdown 阅读视图和编辑器,统一支持:复制 / 剪切 / 粘贴 / 全选。
//
// 设计要点:
// - 单一浮动菜单实例,按需定位显示(同一时刻只可能有一个菜单)
// - 选项根据上下文(是否可编辑 / 是否有选区)动态启用禁用
// - 完全基于 navigator.clipboard,不依赖浏览器默认菜单
// - 显示位置靠近视口边缘时自动向内收缩
// - 在 mousedown / 第二个 contextmenu / ESC / scroll / resize 时自动关闭
const MENU_ID = 'app-context-menu';
/**
* 右键菜单项定义
* @typedef {object} ContextMenuItem
* @property {string} label - 显示文本
* @property {string} value - 点击时回调收到的值
* @property {boolean} [disabled]
* @property {boolean} [separator] - 若为 true 则渲染为分隔条(忽略其它字段)
*/
/**
* 菜单配置
* @typedef {object} ContextMenuShowOptions
* @property {number} x - 视口 x 坐标
* @property {number} y - 视口 y 坐标
* @property {ContextMenuItem[]} items
* @property {(value: string) => void} onSelect
*/
export class ContextMenu {
/**
* @param {object} [options]
* @param {HTMLElement} [options.container] - 菜单挂载容器(默认 document.body)
*/
constructor({ container } = {}) {
this.container = container || document.body;
this.element = null;
this.onSelectCallback = null;
// audit fix (Settings P3 / context-menu focus restore):
// 记录 show 之前 document.activeElement —— hide() 时尝试把焦点还回去。
// 原版没记录,菜单关闭后焦点散落到 <body>,键盘用户失位:
// - 侧栏 file-list 右键 → 选「重命名」后 dialog 打开,关闭后焦点
// 跑回 body 而非原先选中的 file-list item,键盘用户必须再 Tab
// 一遍才能继续上下选。
// - 编辑器右键 → 选「全选」或「粘贴」后,焦点丢在 body。
// 守卫:忽略 <body> / <html>,避免把焦点「还」到根(与 focus-trap.js
// dispose 路径保持同样的 prevFocus 过滤)。
this._prevFocus = null;
this._blurFrame = null;
// 提前 bind,方便 add/removeEventListener 引用同一函数
this._onDocMouseDown = this._onDocMouseDown.bind(this);
this._onDocContextMenu = this._onDocContextMenu.bind(this);
this._onWindowScroll = this._onWindowScroll.bind(this);
this._onWindowResize = this._onWindowResize.bind(this);
this._onKeydown = this._onKeydown.bind(this);
this._onMenuFocusOut = this._onMenuFocusOut.bind(this);
}
/**
* 显示菜单
* @param {ContextMenuShowOptions} options
*/
show({ x, y, items, onSelect }) {
this.hide();
if (!Array.isArray(items) || items.length === 0) return;
this.onSelectCallback = onSelect || null;
// 捕获 prevFocus:在菜单创建之前 activeElement 已经是「用户操作过的元素」,
// hide() 还要把焦点还回它。守卫 body/rootElement(与 focus-trap.js 同套)。
{
const ae = document.activeElement;
this._prevFocus = (ae && ae !== document.body && ae !== document.documentElement
&& typeof ae.focus === 'function') ? ae : null;
}
const menu = document.createElement('div');
menu.id = MENU_ID;
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
// audit L (a11y):menu 隐含 vertical orientation,但显式声明可减少
// 屏幕阅读器误判(NVDA / JAWS 习惯按 aria-orientation 决定是 ←/→ 还是 ↑/↓)
menu.setAttribute('aria-orientation', 'vertical');
/** @type {HTMLButtonElement[]} */
const menuItems = [];
for (const item of items) {
if (item && item.separator) {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
sep.setAttribute('role', 'separator');
menu.appendChild(sep);
continue;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'context-menu-item';
btn.setAttribute('role', 'menuitem');
btn.textContent = item.label;
if (item.disabled) {
btn.disabled = true;
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (btn.disabled) return;
const value = item.value;
// audit fix:先保存回调再 hide() —— hide() 内部会把 this.onSelectCallback
// 清成 null(旧顺序:hide() 在前 → 后续 if 检查永远失败 → 右键菜单点击
// 全部静默失效)。这影响所有右键入口(侧栏 重命名/在文件夹中显示/删除、
// 编辑器 复制/剪切/粘贴/全选)。
const cb = this.onSelectCallback;
this.hide();
if (cb) cb(value);
});
menu.appendChild(btn);
menuItems.push(btn);
}
this.container.appendChild(menu);
this.element = menu;
// 保留菜单项引用,供方向键导航复用。
this._items = menuItems;
// 定位:菜单尺寸必须先知道,所以先 append 再算坐标。
// 视口边缘自适应收缩(避免菜单跑出屏幕外)。
const rect = menu.getBoundingClientRect();
const margin = 4;
const maxX = window.innerWidth - rect.width - margin;
const maxY = window.innerHeight - rect.height - margin;
const left = Math.max(margin, Math.min(x, Math.max(margin, maxX)));
const top = Math.max(margin, Math.min(y, Math.max(margin, maxY)));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
// 关闭监听必须在本次 contextmenu 事件结束之后再绑定,
// 否则本次的 mousedown 不会触发(右键按下时也会派发 mousedown),
// 但其他位置再点右键时本次菜单应被关闭,所以下一次 contextmenu 仍要监听。
// audit #C3 fix (memory leak critical):保存 rAF handle。
// 旧实现:hide() 同步把 this.element = null,但 rAF 回调里依然跑
// addEventListener — 如果用户在同一帧内连续 show→hide→show(旧菜单点选项
// 触发 onSelect 立即 hide,新菜单又 show),第一次的 rAF 仍会 fire,
// 给 document / window 挂上 5 个 listener,且永远不会被 removeEventListener
// 回收(hide 看 this.element === null 直接 early-return)。每个周期泄漏 5 个
// listener,伴随绑定函数对 this 的强引用 → GC 不掉 → 右键速度随时间线性变慢。
// 现在:hide() 先 cancelAnimationFrame,下次 show 再请求;rAF 内若 this.element
// 已不是刚绑定的菜单,则不挂监听(防御性二次校验)。
this._attachFrame = requestAnimationFrame(() => {
this._attachFrame = null;
// 二次校验:rAF fire 时如果 hide 已跑(this.element 已 null),跳过监听绑定。
if (!this.element || this.element !== menu) return;
document.addEventListener('mousedown', this._onDocMouseDown, true);
document.addEventListener('contextmenu', this._onDocContextMenu, true);
window.addEventListener('scroll', this._onWindowScroll, true);
window.addEventListener('resize', this._onWindowResize, true);
document.addEventListener('keydown', this._onKeydown, true);
// audit fix (Settings P3 / context-menu blur close):菜单自身的 focusout。
// 旧版只盯 mousedown / contextmenu / scroll / resize / Esc —— 但用户用
// Tab 把焦点移出菜单再点别处时不会触发 mousedown(focus 已经离开菜单),
// 菜单留在屏幕上"卡死"。挂 focusout 在 capture 阶段,检查 relatedTarget
// 是否仍在菜单内 —— 不在则收起。
menu.addEventListener('focusout', this._onMenuFocusOut, true);
// audit H (a11y):菜单打开后立即把焦点放在第一个非 disabled 项,
// 否则键盘用户没法用菜单(Tab 会跳到下个页面元素)。WAI-ARIA menu 模式
// 要求"打开后焦点进入菜单"。
const first = menuItems.find((b) => !b.disabled);
if (first) first.focus();
});
}
/**
* 主动关闭菜单
*/
hide() {
// 取消未 fire 的 rAF,避免它在我们 detach 之后再去挂监听(见 show 注释)。
if (this._attachFrame != null) {
cancelAnimationFrame(this._attachFrame);
this._attachFrame = null;
}
if (!this.element) return;
const menu = this.element;
document.removeEventListener('mousedown', this._onDocMouseDown, true);
document.removeEventListener('contextmenu', this._onDocContextMenu, true);
window.removeEventListener('scroll', this._onWindowScroll, true);
window.removeEventListener('resize', this._onWindowResize, true);
document.removeEventListener('keydown', this._onKeydown, true);
menu.removeEventListener('focusout', this._onMenuFocusOut, true);
if (menu.parentElement) {
menu.parentElement.removeChild(menu);
}
this.element = null;
this._items = null;
this.onSelectCallback = null;
// audit fix (Settings P3 / context-menu focus restore):hide 完成后
// 把焦点还回 prevFocus。rAF 推一拍 —— 此刻 menu 刚 removeChild,焦点
// 已经因 focusout / mousedown 落到 body;rAF 让出这一帧让用户操作稳定,
// 再把焦点给回 prevFocus,避免被后续的 focus() 事件覆盖。
if (this._prevFocus) {
const target = this._prevFocus;
this._prevFocus = null;
// 二次校验:节点可能被外部重渲染撕下 DOM(file-list 在 files:changed
// 重建),与 focus-trap.js dispose 的 isConnected 守卫保持一致。
if (target.isConnected) {
requestAnimationFrame(() => {
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
});
}
}
}
/** 当前是否处于显示状态 */
isVisible() {
return !!this.element;
}
_onDocMouseDown(e) {
if (this.element && this.element.contains(e.target)) return;
this.hide();
}
_onDocContextMenu(e) {
// 同一菜单内的右键:忽略,让菜单保持显示
if (this.element && this.element.contains(e.target)) return;
// 其它位置的右键:先关掉自己,由后续事件处理器决定是否打开新菜单
this.hide();
}
_onWindowScroll() {
this.hide();
}
_onWindowResize() {
this.hide();
}
_onKeydown(e) {
if (e.key === 'Escape') {
this.hide();
return;
}
// audit H (a11y):menu 模式的方向键导航 —— ↑/↓ 移动焦点(跳过 disabled
// 与 separator),Home/End 跳首尾。WAI-ARIA Authoring Practices 推荐。
if (!this._items || this._items.length === 0) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End') {
// 只在焦点仍在菜单内时拦截 —— 防止外部其它快捷键(如 editor 里的
// 方向键移动光标)被这里吞掉。
const active = document.activeElement;
const inMenu = active && this.element && this.element.contains(active);
if (!inMenu) return;
e.preventDefault();
const enabled = this._items.filter((b) => !b.disabled);
if (enabled.length === 0) return;
let idx = enabled.indexOf(active);
if (idx === -1) idx = e.key === 'ArrowUp' ? enabled.length - 1 : 0;
else if (e.key === 'ArrowDown') idx = (idx + 1) % enabled.length;
else if (e.key === 'ArrowUp') idx = (idx - 1 + enabled.length) % enabled.length;
else if (e.key === 'Home') idx = 0;
else if (e.key === 'End') idx = enabled.length - 1;
enabled[idx].focus();
}
}
// audit fix (Settings P3 / context-menu blur close):
// 焦点离开菜单时收起。
// - capture 阶段抓 —— 让外层 focusout handler 先跑完(例如文件列表
// 自己的 focus 高亮),我们再决定是否要 hide。
// - relatedTarget 为 null(窗口失焦 / DevTools 抓走焦点)也收起,避免
// 菜单"卡在屏幕上谁都看不见"。
// - rAF 推一拍:focusout 与 mousedown 同帧时,rAF 让 mousedown 的
// _onDocMouseDown 路径有机会先关菜单、避免双触发。
_onMenuFocusOut(e) {
if (!this.element) return;
const next = e.relatedTarget;
if (next && this.element.contains(next)) return;
if (this._blurFrame != null) return;
this._blurFrame = requestAnimationFrame(() => {
this._blurFrame = null;
this.hide();
});
}
}

29
src/dirty-sync.js Normal file
View File

@@ -0,0 +1,29 @@
// state.isDirty ↔ editor.isDirty() 单向提升同步 helper(Round 3 bug 修复)
//
// 抽离动机:app.js 的 syncDirtyFromEditor 是模块级闭包函数,无法 import 单测。
// 把「单向上提」的纯逻辑挪到本文件,app.js 里再薄包一层负责 UI 同步
//(syncSaveButton)。这样单测可以纯函数式验证「editor 有未保存字符时 state
// 必须被提升」契约,不需要 mock 一堆 app.js 模块级闭包。
//
// 关键不变量(详见 app.js#syncDirtyFromEditor 注释块):
// - state.isDirty 是 onChange 维护的派生状态;markdown-editor.js updateListener
// 在 IME 合成期间跳过 onChange,state.isDirty 可能 stale。
// - 半步保存场景:state.isDirty=true + editor.isDirty()=false —— 这是有意保留
// 的「飞行期新字符」标记,本函数**绝不**把它盖成 false。
// - 因此本函数只做 false → true 单向提升,永远不做 true → false。
/**
* 把 state.isDirty 同步到 editor.isDirty() 的实时真相(仅提升)。
*
* @param {{ isDirty: boolean }} state - 应用状态对象(仅读 + 写 isDirty)
* @param {{ isDirty: () => boolean }} editor - 编辑器实例(提供 isDirty() 真相)
* @returns {boolean} true 表示发生了 false→true 的提升;false 表示无需提升
* (已是 true / editor 也说没改动 / 半步保存场景保持 true)
*/
export function syncDirtyFromEditor(state, editor) {
if (!state.isDirty && editor.isDirty()) {
state.isDirty = true;
return true;
}
return false;
}

135
src/editor-theme.js Normal file
View File

@@ -0,0 +1,135 @@
// CodeMirror 6 主题 —— 单一来源
//
// 包含:
// - editorThemeSpec CSS 规则 spec(用我们的设计令牌覆盖 oneDark)
// - editorTheme 暗色主题(dark: true → 让 oneDark 接管配色)
// - editorThemeLight 亮色主题(不挂 dark,由 styles.css 的 [data-theme=light] 覆盖)
// - pickEditorTheme 按 body[data-theme] 选择对应 extension 数组
//
// 选择高亮用 `!important`:oneDark 的 (0,5,0) 选择器特异性会盖住我们的规则,
// 否则用户在暗色主题下看到的是 oneDark 偏灰的 selection(看不清)。
//
// 亮色 active line / active gutter / panels / cursor 等属性也用 !important:
// @codemirror/view 的 base theme 用 `&light` / `&dark` 选择器(特异性 (0,2,0)),
// 我们的 `.cm-xxx` 是 (0,1,0),会让 base theme 的硬编码色(亮 #e2f2ff / 暗 #222227)
// 覆盖我们的 var(--bg-hover)。亮色下「活动行 gutter 变浅蓝色块」就是这个原因。
import { EditorView } from '@codemirror/view';
import { oneDark } from '@codemirror/theme-one-dark';
const editorThemeSpec = {
'&': {
// 显式用 var(--bg-viewer),跟 .editor-pane 同色 —— 视觉上仍是同一张画布,
// 但必须显式写:oneDark 内部会给 .cm-editor 涂 #282c34(硬编码),
// 不写会被它盖掉,导致 dark default 下编辑器比预览明显偏灰。
// 用 !important 是因为 oneDark 的扩展在我们之后注入,CSS 源顺序让它赢。
backgroundColor: 'var(--bg-viewer) !important',
color: 'var(--fg-primary) !important',
height: '100%',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-base)',
},
'.cm-content': {
caretColor: 'var(--accent) !important',
// 水平内边距:
// - 左 0:让文字紧贴 gutter 右侧开始,与 .markdown-body 文字起点对齐
// (gutter 自身宽度由 CodeMirror 自适应行号位数,约 30-40px,
// 与 viewer 的 padding-left 32px 偏差 < 10px,可接受)
// - 右 --space-7:与 viewer 的右侧内边距对齐
// 垂直保留 --space-5:编辑器比 viewer 顶部留白小一半,写的时候更紧凑
padding: 'var(--space-5) var(--space-7) var(--space-5) 0',
},
'.cm-cursor, .cm-dropCursor': {
// base theme 给 .cm-cursor 设了黑色,&dark 下设 #528bff(oneDark 的 cursor 蓝)。
// 我们的 accent 颜色被覆盖,需要 !important 盖掉。
borderLeftColor: 'var(--accent) !important',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--accent-selection) !important',
},
'::selection': {
backgroundColor: 'var(--accent-selection) !important',
},
'.cm-activeLine': {
// 同样要盖掉 oneDark 的 #6699ff0b(蓝色调)+ base theme 的 &dark #99eeff33 /
// &light #cceeff44,改用我们中性的 bg-hover
backgroundColor: 'var(--bg-hover) !important',
},
'.cm-activeLineGutter': {
// base theme 用 `&light .cm-activeLineGutter { background-color: #e2f2ff }` /
// `&dark { background-color: #222227 }`,特异性 (0,2,0) 盖住我们的 (0,1,0)。
// 亮色下 active gutter 会变成浅蓝色块;暗色下变成深灰色块 —— 都是 base theme 赢。
// 加 !important 后我们的 var(--bg-hover) 才能稳定生效。
backgroundColor: 'var(--bg-hover) !important',
color: 'var(--fg-secondary) !important',
},
'.cm-gutters': {
// 显式 bg-viewer 盖掉 oneDark 给 .cm-gutters 涂的 #282c34
// —— 否则 gutter 会比 content 明显偏灰,看上去像"编辑区有个奇怪灰条"
backgroundColor: 'var(--bg-viewer) !important',
color: 'var(--fg-tertiary) !important',
border: 'none',
// 钉死宽度 = viewer 的 padding-left(--space-7 = 32px):
// 让 gutter 右侧紧贴的 .cm-content 文字起点 = viewer 文字起点,
// 双栏对比时「编辑」和「预览」同一行的 # 与 一 字真正对齐。
// CodeMirror 默认 gutter 按行号位数自适应(约 30-35px),
// 钉死后 2 位数以内行号仍能放下;超长文件(千行+)再扩。
width: 'var(--space-7)',
minWidth: 'var(--space-7)',
maxWidth: 'var(--space-7)',
},
// 行号单元格:去掉默认 padding,让行号在 32px gutter 内右对齐贴边
'.cm-gutter': {
padding: '0 6px 0 0',
},
'.cm-scroller': {
fontFamily: 'var(--font-mono)',
lineHeight: '1.7',
},
'.cm-panels': {
// base theme `&light .cm-panels { background-color: #f5f5f5; color: black }` /
// `&dark { background-color: #333338; color: white }` 会盖住我们的 (0,1,0) 选择器。
// 搜索面板 / autocomplete 等等都用 .cm-panels,亮色下颜色不对。
backgroundColor: 'var(--bg-elevated) !important',
color: 'var(--fg-primary) !important',
},
};
/** 暗色主题:dark 标记让 oneDark 自动覆盖我们的 CSS 变量 */
export const editorTheme = EditorView.theme(editorThemeSpec, { dark: true });
/** 亮色主题:不带 dark,由 styles.css [data-theme=light] 覆盖色板 */
export const editorThemeLight = EditorView.theme(editorThemeSpec);
// ---- 测试钩 ---------------------------------------------------------------
// editorThemeSpec 不直接 export(避免外部拿到 spec 后乱改)。
// 但 vitest 没法直接断言 EditorView.theme() 的内部 CSSOM 表现 —— 它是私有结构,
// 只能断言我们可控的部分:backgroundColor / color 声明里带了 !important,
// 才能盖住 oneDark 后注入的同 specificity 规则(oneDark 的 .cm-editor { background-color: #282c34 }),
// 以及 @codemirror/view base theme 的 `&light` / `&dark` 规则(特异性 (0,2,0))。
//
// 这些 getter 只在测试用,prod 路径不会触达。
export const __editorThemeSpecForTests = {
get backgroundColor() { return editorThemeSpec['&'].backgroundColor; },
get color() { return editorThemeSpec['&'].color; },
get gutterBackgroundColor() { return editorThemeSpec['.cm-gutters'].backgroundColor; },
get gutterColor() { return editorThemeSpec['.cm-gutters'].color; },
get activeLineBackgroundColor() { return editorThemeSpec['.cm-activeLine'].backgroundColor; },
get activeLineGutterBackgroundColor() { return editorThemeSpec['.cm-activeLineGutter'].backgroundColor; },
get activeLineGutterColor() { return editorThemeSpec['.cm-activeLineGutter'].color; },
get cursorBorderLeftColor() { return editorThemeSpec['.cm-cursor, .cm-dropCursor'].borderLeftColor; },
get caretColor() { return editorThemeSpec['.cm-content'].caretColor; },
get panelsBackgroundColor() { return editorThemeSpec['.cm-panels'].backgroundColor; },
get panelsColor() { return editorThemeSpec['.cm-panels'].color; },
};
/**
* 按 body[data-theme] 选择当前主题的 extensions 数组。
* 暗色叠加 oneDark(语法高亮);亮色只用自己的覆盖。
*
* @param {'dark' | 'light' | undefined} theme
* @returns {Array}
*/
export function pickEditorTheme(theme) {
return theme === 'light' ? [editorThemeLight] : [oneDark, editorTheme];
}

82
src/editor-toolbar.js Normal file
View File

@@ -0,0 +1,82 @@
// 视图模式切换控件
// 三态:preview(默认)/ edit / split
// 状态写 document.body.dataset.editorMode,CSS 据此切布局。
export const EDITOR_MODES = ['preview', 'edit', 'split'];
export class EditorToolbar {
/**
* @param {object} options
* @param {object} options.elements - 三个按钮元素引用 { btnPreview, btnEdit, btnSplit }
* @param {(mode:'preview'|'edit'|'split') => void} options.onChange - 模式变化回调
*/
constructor({ elements, onChange }) {
this.elements = elements;
this.onChange = onChange;
if (elements.btnPreview) {
elements.btnPreview.addEventListener('click', () => this.selectMode('preview'));
}
if (elements.btnEdit) {
elements.btnEdit.addEventListener('click', () => this.selectMode('edit'));
}
if (elements.btnSplit) {
elements.btnSplit.addEventListener('click', () => this.selectMode('split'));
}
}
/**
* 由用户点击按钮或外部 API 主动选择模式。触发 onChange(用于持久化)。
* @param {'preview'|'edit'|'split'} mode
*/
selectMode(mode) {
if (!EDITOR_MODES.includes(mode)) return;
if (this.getMode() === mode) return; // 同模式 → 不重复触发
this._setDOM(mode);
if (this.onChange) this.onChange(mode);
}
/**
* 同步 DOM 与按钮高亮,但**不**触发 onChange。
* 用于「外部(持久化设置)改变了模式」的场景,避免循环。
* @param {'preview'|'edit'|'split'} mode
*/
setMode(mode) {
if (!EDITOR_MODES.includes(mode)) return;
this._setDOM(mode);
}
/**
* 取当前模式(从 dataset 读,保证跟 DOM 一致)
*/
getMode() {
return document.body.dataset.editorMode || 'preview';
}
/**
* 三态循环:preview → edit → split → preview
* 触发 onChange 回调。
*/
cycle() {
const cur = this.getMode();
const idx = EDITOR_MODES.indexOf(cur);
const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length];
this.selectMode(next);
}
_setDOM(mode) {
document.body.dataset.editorMode = mode;
const map = {
preview: this.elements.btnPreview,
edit: this.elements.btnEdit,
split: this.elements.btnSplit,
};
Object.entries(map).forEach(([k, btn]) => {
if (!btn) return;
const active = k === mode;
btn.classList.toggle('is-active', active);
// 让屏幕阅读器/键盘用户也能感知当前模式;icon-only 按钮尤其依赖此属性
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
}

50
src/event-emitter.js Normal file
View File

@@ -0,0 +1,50 @@
// 极简 EventEmitter
// SettingsStore 等组件需要的事件订阅基础设施。
// 与 Todo List 的实现保持完全一致 —— 不引入任何外部依赖。
export class EventEmitter {
constructor() {
this._handlers = new Map();
}
on(event, handler) {
if (!this._handlers.has(event)) {
this._handlers.set(event, new Set());
}
this._handlers.get(event).add(handler);
return () => this.off(event, handler);
}
off(event, handler) {
const set = this._handlers.get(event);
if (set) set.delete(handler);
}
emit(event, payload) {
const set = this._handlers.get(event);
if (!set || set.size === 0) return;
// audit fix (Settings P3 / event-emitter snapshot):派发时先对 Set
// 拍一份快照。Set 在迭代期间的修改语义:
// - 新增的 handler:会被访问(ECMA-262 Set 规范)。
// - 删除的 handler(如果已访问过):不影响本次迭代。
// - 删除当前正在访问的 handler:行为未定义 / 跨引擎不一致。
// 一个 handler 内部再去 on/off 同名事件就会触发上面第三条:
// - 副作用"重订阅"导致本次 emit 又派发给新 handler(新 handler 收到
// 上一轮事件,常见 bug)。
// - 副作用"自 off"导致部分 handler 被跳过。
// - handler 抛错后被 catch 但后续 handler 仍需正常执行 —— 这条原来 OK。
// 拍快照后所有路径确定性,跨 V8 / JSC / SpiderMonkey 行为一致。
// 性能:O(n) copy,对 settingsStore 等小规模事件总线无可观测开销。
const snapshot = Array.from(set);
for (const h of snapshot) {
// 二次校验:handler 在前面的执行里可能 off 自己(snapshot 拿到时还在
// set 里,但下面那次循环已 off),跳过避免重复派发。
if (!set.has(h)) continue;
try {
h(payload);
} catch (e) {
console.error(`[EventEmitter] handler for "${event}" threw:`, e);
}
}
}
}

68
src/feedback.js Normal file
View File

@@ -0,0 +1,68 @@
// 轻量反馈组件
// 仅保留 toast —— Notes 是只读阅读器,不需要 confirmDialog / inputDialog / contextMenu。
const TOAST_DEFAULT_DURATION = 3000;
// 同时存在的 toast 上限 —— 超出时关闭最早创建的,避免极端情况下无限堆积占满 DOM。
const MAX_VISIBLE_TOASTS = 5;
/**
* 显示一个 toast 消息
* @param {string} message - 消息内容
* @param {'info'|'success'|'warning'|'error'} [type='info']
* @param {number} [duration=3000] - 自动关闭毫秒数;0 表示不自动关闭
*/
export function showToast(message, type = 'info', duration = TOAST_DEFAULT_DURATION) {
const container = document.getElementById('toast-container');
if (!container) {
// 无 toast container 是早期启动或测试环境(#toast-container 还没挂上),
// 静默丢弃 —— 这里原本的 console.log 会在生产控制台产生噪声,没人在读。
return;
}
// 超出上限时立刻同步移除最早的 toast(不等淡出动画)—— 顶部满的时候底部瞬间
// 消失一条是可接受的,比排队等淡出再插队让用户以为 toast 卡住了要直观。
// 同时打上 dismissed 标记,避免它自身的自动关闭 timer / click handler
// 在 toast 已脱离 DOM 后还跑回调(虽然 parentElement 检查能拦住,但
// dataset 检查短路更稳,少一次 console 输出)。
while (container.children.length >= MAX_VISIBLE_TOASTS) {
const oldest = container.firstElementChild;
if (!oldest) break;
if (oldest.parentElement === container) {
oldest.dataset.dismissed = '1';
container.removeChild(oldest);
}
}
const toast = document.createElement('div');
toast.className = `toast is-${type}`;
toast.setAttribute('role', 'status');
toast.textContent = message;
container.appendChild(toast);
let timer = null;
if (duration > 0) {
timer = setTimeout(() => dismiss(), duration);
}
// 点击立即关闭
toast.addEventListener('click', () => dismiss());
function dismiss() {
if (timer) {
clearTimeout(timer);
timer = null;
}
// 防止 transition 重入:若已经在淡出中,直接返回
if (toast.dataset.dismissed === '1') return;
toast.dataset.dismissed = '1';
toast.style.opacity = '0';
toast.style.transform = 'translateY(-4px)';
toast.style.transition = 'opacity 150ms ease, transform 150ms ease';
setTimeout(() => {
if (toast.parentElement === container) {
container.removeChild(toast);
}
}, 150);
}
}

476
src/file-list.js Normal file
View File

@@ -0,0 +1,476 @@
// 侧边栏文件列表组件
// 职责:渲染文件列表 + 处理点击 + 处理搜索过滤 + 排序
//
// 与 Todo List 的 sidebar.js 区别:移除分类、子分类、右键菜单、拖拽、智能列表。
// 只保留 "扁平文件列表 + 点击打开 + 搜索过滤 + 排序" 这四件事。
//
// Stage 8(Folder Browser):
// 列表里可以同时出现三种条目:
// - entryType === 'folder' : 进入子目录 → onSelect(entry) 中 entryType === 'folder'
// - entryType === 'editable': 打开编辑 → onSelect(entry) 中 entryType === 'editable'
// - entryType === 'binary' : 不支持打开,点击弹 toast → onSelect(entry) 中 entryType === 'binary'
// 渲染端 (app.js) 在 onSelect 里按 entryType 分发;本组件只负责按类型画图标 + 类名。
// data-path 仍用绝对路径(文件夹也有 path = 绝对目录),保持事件委托反查统一。
import { formatSize, formatRelativeTime, pathsEqual } from './utils.js';
import { createDisposable } from './component.js';
/**
* 文件名 fuzzy 匹配(子序列 + 评分)
*
* 算法:query 的每个字符按顺序出现在 name 里(不要求相邻)。
* - 大写 / 小写不敏感(两端都 lowercase 后比较)
* - 越靠前的命中越得分高(开头匹配 > 末尾匹配)
* - 单词边界命中再加分(`n` 出现在「_」/「.」/「-」后立刻得分 boost)
* - 完全包含子串 → 等同 fuzzy 得分 +50(避免「完全匹配反而排后」的反直觉)
*
* 返回 0 表示不匹配(filter 直接丢弃)。
*
* 约定:query 必须是小写。FileList 在 input handler 处统一做了 lowercase,
* 这里不再重复 —— N 个文件就少 N 次 String#toLowerCase 调用。
*
* @param {string} name - 文件名(含扩展名)
* @param {string} lowerQuery - 已 lowercase 的搜索词
* @returns {number} 匹配分数;0 = 不匹配
*/
function fuzzyScore(name, lowerQuery) {
if (!lowerQuery) return 1; // 空 query = 不过滤
const n = name.toLowerCase();
// 子串包含 → 直接高分(fuzzy 子序列可能反而排后面)
let idx = n.indexOf(lowerQuery);
if (idx >= 0) {
// 靠前 + 单词边界 + 命中整词 → 更高分
let bonus = 0;
if (idx === 0) bonus += 30;
else if (idx > 0 && /[_\-.\s]/.test(n[idx - 1])) bonus += 20;
return 100 + bonus - idx;
}
// 子序列匹配(fuzzy)
let qi = 0;
let score = 0;
let lastMatchIdx = -1;
for (let ni = 0; ni < n.length && qi < lowerQuery.length; ni++) {
if (n[ni] === lowerQuery[qi]) {
// 相邻匹配加分(连续命中),否则只加基础分
if (lastMatchIdx === ni - 1) score += 8;
else score += 5;
// 单词边界 bonus
if (ni === 0 || /[_\-.\s]/.test(n[ni - 1])) score += 4;
// 位置越靠前 bonus 越多
score += Math.max(0, 10 - Math.floor(ni / 3));
lastMatchIdx = ni;
qi++;
}
}
if (qi < lowerQuery.length) return 0; // 没匹配完所有字符
return score;
}
/**
* 文件列表组件
*/
export class FileList {
/**
* @param {object} options
* @param {HTMLUListElement} options.element - <ul> 容器
* @param {HTMLInputElement} options.searchInput - 搜索输入框
* @param {(file: {path:string,name:string}) => void} options.onSelect - 点击文件回调
* @param {string} [options.sortMode='name'] - 排序模式:'name' | 'mtime-desc'
*/
constructor({ element, searchInput, onSelect, onContextMenu, sortMode = 'name' }) {
this.element = element;
this.searchInput = searchInput;
this.onSelect = onSelect;
this.onContextMenu = onContextMenu;
this.files = [];
this.filteredFiles = [];
this.activePath = null;
this.searchTerm = '';
this.sortMode = sortMode === 'mtime-desc' ? 'mtime-desc' : 'name';
/** path → file,供事件委托时反查(避免每个 <li> 挂一个闭包) */
this._byPath = new Map();
// 统一 lifecycle:dispose() 一次性 unbind 所有 listener。
createDisposable(this);
// 事件委托:整个列表只挂一对 click / keydown 监听。
// 原来是每次 render 给每个 <li> 各挂两个闭包 —— 列表一重建就要重新分配 2N 个函数,
// 搜索时每敲一个字符都重建一次。委托后 render 只管建 DOM。
if (element) {
element.addEventListener('click', (e) => {
const file = this._fileFromEvent(e);
if (file && this.onSelect) this.onSelect(file);
}, { signal: this.signal });
// 键盘激活与导航:Enter/Space 等价于点击;↑/↓/Home/End 走 moveActive
// (moveActive 只挪高亮,不会打开文件 —— 打开动作由 Enter/Space 触发,
// 走 _fileFromEvent(e) 从当前 focus 所在 LI 反查文件)。
element.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault(); this.moveActive(1); return;
}
if (e.key === 'ArrowUp') {
e.preventDefault(); this.moveActive(-1); return;
}
if (e.key === 'Home') {
e.preventDefault(); this.moveActive('first'); return;
}
if (e.key === 'End') {
e.preventDefault(); this.moveActive('last'); return;
}
if (e.key === 'Enter' || e.key === ' ') {
const file = this._fileFromEvent(e);
if (!file) return;
e.preventDefault();
if (this.onSelect) this.onSelect(file);
}
}, { signal: this.signal });
// 右键菜单:弹给调用方处理(context-menu.js)
element.addEventListener('contextmenu', (e) => {
const file = this._fileFromEvent(e);
if (!file || !this.onContextMenu) return;
e.preventDefault();
this.onContextMenu(file, e.clientX, e.clientY);
}, { signal: this.signal });
}
if (searchInput) {
// 100ms trailing debounce —— 文件多时(1000+)每按一键全量重算 + 重渲染
// 是 O(N) 的工作,连续输入会卡;debounce 让连续打字期间 CPU 闲下来。
// { signal: this.signal } 走 AbortController 路径,dispose 时自动解绑。
let searchTimer = null;
const flushSearch = () => {
searchTimer = null;
this.searchTerm = searchInput.value.trim().toLowerCase();
this.applyFilter();
this.render();
};
// dispose 时把挂起的 debounce 定时器清掉 —— 否则它会在组件已销毁后
// 触发 flushSearch,访问已被释放的 this.searchTerm / applyFilter / render。
// 见 createDisposable.addAbort 的「abort 时一次性执行清理」语义。
this.addAbort(() => {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
});
searchInput.addEventListener('input', () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(flushSearch, 100);
}, { signal: this.signal });
// 失焦时立即落盘当前值,不等 debounce —— 用户离开搜索框后还看到旧结果会困惑
searchInput.addEventListener('blur', () => {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
flushSearch();
}
}, { signal: this.signal });
}
}
/**
* 从委托事件里反查对应的 file 对象。
* @param {Event} e
* @returns {object|null}
*/
_fileFromEvent(e) {
const li = e.target.closest && e.target.closest('.file-item');
if (!li || !this.element.contains(li)) return null;
return this._byPath.get(li.dataset.path) || null;
}
/**
* 设置排序模式(供设置对话框等调用方热切换)
* @param {'name' | 'mtime-desc'} mode
*/
setSort(mode) {
const next = mode === 'mtime-desc' ? 'mtime-desc' : 'name';
if (next === this.sortMode) return;
this.sortMode = next;
this.applyFilter();
this.render();
}
/**
* 用新的条目列表替换当前列表
*
* Stage 8:接受混合条目(文件夹 + editable + binary)。每条至少含
* { name, path, entryType: 'folder'|'editable'|'binary', isFolder?: boolean, size?, mtimeMs? }
* 向后兼容:旧调用传 { name, path, size, mtimeMs }(无 entryType)→ 视为 'editable'。
*
* @param {Array<object>} entries
*/
setFiles(entries) {
const previousActive = this.activePath;
this.files = Array.isArray(entries)
? entries.map((e) => ({
...e,
entryType: e.entryType || (e.isFolder ? 'folder' : 'editable'),
}))
: [];
// M-series fix (audit Phase M-H2):移除 setFiles 内的 onFileRemoved 触发。
// 旧实现仅凭「previousActive 不在新 entries 里」就通知上层清空编辑器——
// 这把「用户导航到子目录」(Folder Browser 语义:浏览期间不自动关文件)
// 与「文件被外部删除」混淆为同一信号,导致切子目录时编辑器被错误清空。
//
// 改成 setFiles 只负责渲染,外部清理判定上移到 app.js#applyEntries:
// 用「文件目录 === 当前浏览目录」区分「文件被删」与「导航到别处」。
// signature:`activePath` 与新 entries 不再自动清零 —— 由调用方决定要不要
// 通过 setActive(null) 主动清,否则高亮会跨目录污染。
// fix(audit 2026-08):Windows 路径大小写不敏感,=== 会让外部重命名(小
// 小写差异)后侧栏失去高亮。统一走 pathsEqual。
//
// 审计修复 (Round 11 P1-1):用 fs 返回的真实 case 覆盖 previousActive。
// 旧逻辑保留 previousActive(用户输入时的大小写),state.currentFile.path
// 与侧栏 data-path 出现大小写漂移,IPC / 日志 / 错误消息里看着像两个文件。
// 现在回迁时优先取 this.files 里的真值(fs 返回的磁盘 case),IPC 跨进程
// 看到的路径字符串统一。
const canonicalForActive = (active) => {
if (!active) return active;
const hit = this.files.find((f) => pathsEqual(f.path, active) && f.entryType !== 'folder');
return hit ? hit.path : active;
};
if (previousActive
&& !this.files.some(f => pathsEqual(f.path, previousActive) && f.entryType !== 'folder')) {
// audit fix (Round 9):保留「activePath 跨目录导航」回迁语义。
// 之前这里直接 activePath=null,用户切走再切回原目录时 entries 又含
// previousActive,但 activePath 已被清,侧栏不自动恢复高亮。
// 改成:当前目录不含 previousActive 时不主动清,存到 lastSeenActivePath
// 等下次 setFiles 时回迁;如果上层调 setActive(null) 显式清则一切如旧。
this.lastSeenActivePath = previousActive;
this.activePath = null;
} else if (previousActive
&& this.files.some(f => pathsEqual(f.path, previousActive) && f.entryType !== 'folder')) {
// 回迁到原目录(entries 又含 previousActive)→ 恢复 activePath,
// 同时把大小写统一到 fs 真实 case(见上 canonicalForActive 注释)。
this.activePath = canonicalForActive(previousActive);
this.lastSeenActivePath = null;
} else if (this.activePath) {
// 兜底:当前 activePath 还在 setFiles 命中的新 entries 里时,统一一次大小写。
// 例如外部 openFile 后接着 setFiles 没经过回迁分支,activePath 仍可能是旧 case。
this.activePath = canonicalForActive(this.activePath);
}
this.applyFilter();
this.render();
}
/**
* 设置当前激活的文件(侧边栏高亮 + 调用方同步状态栏)
*
* 只挪 is-active 类,不重建列表 —— 这是最高频的渲染触发点(每次打开文件都走),
* 而列表内容并没有变化,重建一遍纯属浪费。
* @param {string|null} filePath
*/
setActive(filePath) {
const next = filePath || null;
if (next === this.activePath) return;
this.activePath = next;
if (!this.element) return;
// audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入
// 的 case,fs 返回的是磁盘真实 case。直接 === 比对会让侧栏永远找不到 active
// 条目、不显示高亮。统一走 pathsEqual(含 Windows case-fold)。
for (const li of this.element.children) {
const active = pathsEqual(li.dataset.path, this.activePath);
li.classList.toggle('is-active', active);
li.setAttribute('aria-selected', active ? 'true' : 'false');
}
}
/**
* 列表获焦时由键盘导航调用:移动激活态到相邻项。只挪高亮 + 焦点,
* 不触发 onSelect —— 打开文件由 Enter / Space 键负责(走 _fileFromEvent)。
* 旧版本在这里直接 onSelect,导致每按一次方向键就打开一个文件。
* @param {number} delta -1 上一项 / +1 下一项 / 'first' / 'last'
*/
moveActive(delta) {
const items = Array.from(this.element.querySelectorAll('.file-item'));
if (items.length === 0) return;
// audit fix:与 setActive 保持一致 —— Windows 上 case-fold 比对。
let idx = items.findIndex(li => pathsEqual(li.dataset.path, this.activePath));
if (idx < 0) idx = 0;
if (delta === 'first') idx = 0;
else if (delta === 'last') idx = items.length - 1;
else idx = Math.max(0, Math.min(items.length - 1, idx + delta));
const target = items[idx];
// 直接给目标项加高亮,避免 setActive→render 整树重画把焦点带走
items.forEach(li => {
const active = li === target;
li.classList.toggle('is-active', active);
li.setAttribute('aria-selected', active ? 'true' : 'false');
});
this.activePath = target.dataset.path;
// audit fix (2.4):focus 默认会触发 scrollIntoView → 侧栏布局可能跳动。
// 改用 preventScroll,再用 scrollIntoView('nearest') 把项拉到可视区但不强制居中。
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
try { target.scrollIntoView({ block: 'nearest' }); } catch { /* ignore */ }
}
/**
* 应用搜索过滤 + 排序
*
* 搜索:先按 fuzzy 评分过滤 + 排序(高分在前),
* 再按 sortMode 排序(name 或 mtime) —— 排序优先级:
* 1) searchTerm 存在 → fuzzy 排序覆盖 sortMode(用户明确在搜)
* 2) searchTerm 为空 → 走 sortMode
*
* 这样 "wel" 仍能在 mtime-desc 模式下把 welcome.md 排前,而不是按修改时间硬排。
*/
applyFilter() {
const term = this.searchTerm;
let list;
if (!term) {
list = this.files.slice();
} else {
// fuzzy:打分并过滤
const scored = [];
for (const f of this.files) {
const score = fuzzyScore(f.name, term);
if (score > 0) scored.push({ file: f, score });
}
scored.sort((a, b) => b.score - a.score);
list = scored.map(s => s.file);
}
// 仅在无搜索时使用 sortMode;有搜索时 fuzzy 排序已覆盖
if (!term) {
if (this.sortMode === 'mtime-desc') {
list.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
} else {
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
}
}
this.filteredFiles = list;
}
/**
* 渲染 DOM
*/
render() {
if (!this.element) return;
this.element.replaceChildren();
// 重建 path → file 索引,供委托的 click / keydown 反查
this._byPath.clear();
for (const file of this.filteredFiles) {
this._byPath.set(file.path, file);
}
if (this.filteredFiles.length === 0) {
// 空状态分两种:搜索无结果 vs 目录真为空。
// - 搜索有词 → "没找到匹配项"(让用户清空搜索或换关键词)
// - 搜索为空 → "此文件夹为空"(index.html 的 data-empty 默认值;
// setFiles 调用时 element 还没渲染数据时 element.dataset.empty 还是这个)
// 这样 UI 不会把"搜不到"误读成"目录空",也避免反过来。
const emptyMsg = this.searchTerm
? `没有匹配 “${this.searchTerm}” 的文件`
: '此文件夹为空';
this.element.dataset.empty = emptyMsg;
return;
}
// 有内容时清掉 data-empty,避免渲染期间被 :empty::after 短暂闪一下
this.element.removeAttribute('data-empty');
const term = this.searchTerm;
const frag = document.createDocumentFragment();
for (const file of this.filteredFiles) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.path = file.path;
// entryType / isFolder 是渲染端做图标 / 灰态分流的依据,
// 也让右键菜单 / onSelect 分发有统一锚点。
li.dataset.entryType = file.entryType;
li.classList.add(`is-${file.entryType}`);
// 列表项自身是按钮语义:通过 role/tabindex/aria-selected 把键盘可达性补齐
// (容器 ul 已经声明 role="listbox",单项用 role="option" 是规范对应)
li.setAttribute('role', 'option');
li.tabIndex = 0;
// audit fix:Windows 路径大小写不敏感;fs 返回的真实 case 不一定等于
// state.currentFile.path 上记录的 case,统一走 pathsEqual。
const isActive = pathsEqual(file.path, this.activePath);
if (isActive) {
li.classList.add('is-active');
li.setAttribute('aria-selected', 'true');
} else {
li.setAttribute('aria-selected', 'false');
}
const name = document.createElement('div');
name.className = 'file-name';
// 搜索高亮:纯 textContent + createElement('mark'),避免 innerHTML 引入 XSS
if (term) {
const lowerName = file.name.toLowerCase();
const idx = lowerName.indexOf(term);
if (idx >= 0) {
const before = file.name.slice(0, idx);
const hit = file.name.slice(idx, idx + term.length);
const after = file.name.slice(idx + term.length);
if (before) name.appendChild(document.createTextNode(before));
const mark = document.createElement('mark');
mark.textContent = hit;
name.appendChild(mark);
if (after) name.appendChild(document.createTextNode(after));
} else {
name.textContent = file.name;
}
} else {
name.textContent = file.name;
}
const meta = document.createElement('div');
meta.className = 'file-meta';
// Stage 8:folder / binary 没有 size+mtime,只有 editable 显示完整 meta。
// folder 显示「文件夹」;binary 显示大小 + 「不可编辑」提示。
if (file.entryType === 'folder') {
meta.textContent = '文件夹';
} else if (file.entryType === 'binary') {
meta.textContent = `${formatSize(file.size)} · 不可编辑`;
} else {
meta.textContent = `${formatSize(file.size)} · ${formatRelativeTime(file.mtimeMs)}`;
}
// 文件图标:左侧装饰,让 list 项视觉更有「文件感」。
// aria-hidden,因为纯视觉;语义信息已经在 file-name 里。
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
icon.setAttribute('class', 'file-icon');
icon.setAttribute('viewBox', '0 0 24 24');
icon.setAttribute('width', '14');
icon.setAttribute('height', '14');
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '1.8');
icon.setAttribute('stroke-linecap', 'round');
icon.setAttribute('stroke-linejoin', 'round');
icon.setAttribute('aria-hidden', 'true');
// 不同类型用不同 SVG path:folder / file / binary(带斜杠禁止感)
if (file.entryType === 'folder') {
icon.innerHTML = '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
} else if (file.entryType === 'binary') {
// 文件 + 斜杠:表达「不可编辑」
icon.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="5" y1="5" x2="19" y2="19"/>';
} else {
icon.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>';
}
// 文本容器:把 name + meta 包成一列,让整体用 grid 布局对齐 icon。
// 直接 append name/meta 到 li 会让它们和 icon 一起进入 grid 的同一列,
// 排版混乱;所以包一层 .file-text。
const text = document.createElement('div');
text.className = 'file-text';
text.appendChild(name);
text.appendChild(meta);
li.appendChild(icon);
li.appendChild(text);
// 点击 / 键盘由构造函数里的委托监听统一处理,这里不再逐项绑定
frag.appendChild(li);
}
this.element.appendChild(frag);
}
}

481
src/file-ops.js Normal file
View File

@@ -0,0 +1,481 @@
// 文件操作层(Stage 4c 抽离)
//
// 渲染端文件 CRUD:创建、重命名、删除、显示。
// 全部走 IPC 调用主进程,结果落到 toast / state / 当前视图上。
//
// 设计:
// - 接收上下文对象(state / controls / api / callbacks),不直接 import app.js
// - 通过 createFileOps(ctx) factory 暴露,避免任何模块级副作用
// - 公开 API 仅 showFileListContextMenu / createNewFile;其他函数仅内部互调
//
// 边界:
// - 不读 currentFile / isDirty 等 state 字段之外的全局
// - 不调 updateWindowTitle / syncSaveButton 等 UI 同步函数以外的回调
// (完整名单见 ctx.callbacks)
// 文件系统 errno → 中文友好提示:Round 4 收尾后抽离到 shared/friendly-fs-error.js,
// preload 暴露成 window.api.friendlyFsError。本文件内部 + 测试都走这一份,
// 不再各自维护 mapping —— 避免 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 文案
// 漂移。本文件调用点直接用 window.api.friendlyFsError(...)。
import { showToast } from './feedback.js';
import { showPromptDialog } from './prompt-dialog.js';
import { showConfirmDialog } from './confirm-dialog.js';
import { isMarkdownFile } from './utils.js';
// 审计修复 (Round 11 deep-fix P1-1):create / delete / rename 三个回调的入口
// 在读 state.isDirty 之前必须先 syncDirtyFromEditor —— 否则 CJK 用户在 IME
// 合成期调「新建 / 删除 / 重命名」时,state.isDirty 是 stale false,跳过
// dirty 确认 → 直接销毁用户当前编辑的内容。app.js save() / openFile() /
// 关窗已经走这个路径(详见 src/dirty-sync.js 注释);file-ops 这一层之前漏了。
import { syncDirtyFromEditor } from './dirty-sync.js';
// 审计修复 (Round 11 vol.2):客户端文件名预校验。
//
// 镜像 main/file-ops.js resolveRenameName / resolveFileName 的「输入合法性」检查
// (路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符 / 非空)。
// 不镜像 main 的「存在性 + 自动避让」逻辑 —— 那是 IPC 端的事,rename/create 都
// 走 main 协商,不在 renderer 重复实现。
//
// 为什么不共享同一份代码:main 是 CJS(require('fs') + path.join 路径协商),
// renderer 是 ESM 且不能 require Node 模块。共享会带来上下文桥 / bundle 复杂度。
// 退而求其次:把这条规则文档化在 main + 本注释,变更时手工同步 —— 改动频次极低
// (Windows 保留设备名清单基本不变),双拷贝维护成本可控。
const WIN_RESERVED_DEVICE_NAMES = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]);
/**
* 校验输入是否可作为文件名(含扩展名或无)。返回 null = 通过;返回字符串 = 错误消息。
* 用于 prompt-dialog 的 validate 选项,在用户点确认时立即拦截,不让非法值走到 IPC。
*
* @param {string} raw
* @returns {string|null}
*/
function validateFileName(raw) {
if (typeof raw !== 'string' || !raw.trim()) return '文件名不能为空';
const name = raw.trim();
if (/[/\\]/.test(name) || name.includes('..')) return '文件名不能包含路径分隔符或 ..';
if (/[<>:"|?*]/.test(name)) return '文件名包含 Windows 保留字符(< > : " | ? *)';
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(name)) return '文件名包含非法字符';
// Windows 保留设备名(取最后一个 . 之前的部分判断;与 main resolveRenameName 保持一致)
const lastDot = name.lastIndexOf('.');
const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) {
return `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名`;
}
return null;
}
/**
* @typedef {object} FileOpsCtx
* @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs)
* @property {object} controls - { fileList, viewer, editor, contextMenu }
* @property {object} api - preload contextBridge 暴露的 window.api
* @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? }
*/
/**
* @param {FileOpsCtx} ctx
*/
export function createFileOps(ctx) {
const { state, controls, api, callbacks } = ctx;
if (!state || !controls || !api || !callbacks) {
throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks');
}
const { contextMenu, viewer, fileList, editor } = controls;
const { openFile, updateWindowTitle, clearCurrentFile } = callbacks;
/**
* 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名)。
*/
function formatStampForName(d) {
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
}
/**
* 在系统文件管理器中显示条目(文件 / 文件夹 分支)。
* - 文件(editable / binary):走 `shell:show-item-in-folder`,通过
* shell.showItemInFolder 实现「打开父目录并选中文件」语义
* (macOS = Reveal,Win/Linux = 资源管理器选中)。
* - 文件夹:走 `shell:open-dir`,通过 shell.openPath 直接打开目录本身
* —— showItemInFolder 在主进程会 stat.isFile() 校验,目录会被拒
* (NOT_A_FILE),用户点「在文件夹中显示」反而弹错误 toast。
*
* Stage 8:folder 条目仍保留该入口,期望是「用系统文件管理器打开这个目录」
* (参见 showFileListContextMenu 注释)。
*/
async function revealFile(file) {
if (!file || !file.path) return;
// 文件夹条目不能用 showItemInFolder(主进程会 stat.isFile() 校验,
// 目录被拒 → 弹「不是一个文件」)。改走 openDir,让 shell.openPath
// 打开目录本身(Win/linux 进 Explorer,macOS 进 Finder)。
const isFolder = file.entryType === 'folder';
const result = isFolder
? await api.openDir(file.path)
: await api.showItemInFolder(file.path);
if (!result || !result.ok) {
showToast(`显示失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
}
}
/**
* 删除文件:弹 confirm → 走 shell.trashItem → 如果是当前打开的文件则清空视图。
* 即便 IPC 返回 FILE_NOT_FOUND,也照样清空视图(说明文件已不在数据目录里)。
*
* Phase 2 audit 修复 (P1 BUG #4):删除飞行期 fs-watcher 可能先推 files:changed
* (A 不在 entries),onFilesChanged 的 rescue 分支会把当前 dirty 内容救到
* `<A>-rescued-<stamp>.md`(与被删的 A 内容相同),然后 clearCurrentFile —
* 这是「删除」操作的孤儿副作用。修复:进 deleteFile 前设 state.deletingPath =
* file.path;onFilesChanged rescue 分支遇此标记跳过 rescue。finally 清标记
* —— 不论 IPC 成功/失败/discarded。
*/
async function deleteFile(file) {
if (!file || !file.path) return;
// 审计修复 (Round 11 deep-fix P1-1):syncDirtyFromEditor 把 editor.isDirty()
// 真相同步到 state.isDirty,避免 IME 合成期删除当前文件时跳过 dirty 确认
// 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。
syncDirtyFromEditor(state, editor);
const isCurrent = state.currentFile && state.currentFile.path === file.path;
// 删除当前文件 + 有未保存改动 → 二次确认
if (isCurrent && state.isDirty) {
const choice = await showConfirmDialog({
title: '删除未保存的文件',
message: `「${file.name}」有未保存改动,确定要删除(移至回收站)吗?`,
icon: 'danger',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '删除', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
}
// Phase 2 audit (P1 BUG #4):设删除守卫,挡住 fs-watcher rescue 误触发
state.deletingPath = file.path;
try {
const result = await api.deleteFile(file.path);
if (!result || !result.ok) {
if (result && result.error === 'FILE_NOT_FOUND') {
// 文件已不在了(用户手动删过、外部工具删过)—— 仿照 renameCurrentOrFile
// 的 FILE_NOT_FOUND 路径:dirty 状态下不能直接 clearCurrentFile 丢内容。
// 先取 editor.getContent() 救到 <base>-rescued-<stamp>-<suffix>.md,再清视图。
// audit fix (Round 4 F1):之前 deleteFile 的 FILE_NOT_FOUND 直接 clearCurrentFile
// → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged
// rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
const originalName = state.currentFile.name || 'untitled.md';
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : '';
// 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。
api.createFile(rescueName, rescueContent).then((r) => {
if (r && r.ok) {
showToast(`文件已不在;未保存内容已救回到「${rescueName}」`, 'warning', 4500);
} else {
showToast(`文件已不在;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500);
}
}).catch((e) => {
console.error('[file-ops] delete FILE_NOT_FOUND rescue IPC 异常:', e);
showToast('文件已不在;未保存内容丢失', 'error', 4500);
});
} else {
showToast('文件已不存在', 'warning');
}
} else {
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
}
showToast(`已移至回收站:${file.name}`, 'success', 1500);
if (isCurrent) {
// 走集中清理:状态归零 + 清空 viewer/editor + 解除文件列表高亮。
// 注意不再单独写 syncSaveButton / updateWindowTitle / updateStatusStats,
// clearCurrentFile 内部已统一处理(并把 lastSavedMtimeMs 重置为 null,
// 而不是这里的 0 —— 与文件被删、目录切换等其它路径行为一致)。
clearCurrentFile();
}
} finally {
// 不论成败都清守卫 —— 否则下次 fs-watcher 事件还会被误判为「正在删除」
state.deletingPath = null;
}
}
/**
* 重命名文件:弹 prompt 让用户输入新文件名。
* 若是当前打开的文件,同步 state.currentFile 的 path/name 以让 UI 反映。
*
* 用户反馈:「重命名不要自动补后缀」—— 这里显示完整文件名(含 .md)让用户
* 自由编辑:可以改成 bar.md / bar.txt / 干脆去掉扩展名。新建文件场景也
* 走同一思路(createNewFile 注释):默认值直接带 .md,主进程
* resolveFileName / resolveRenameName 都「用户输入什么就用什么」。
*/
async function renameCurrentOrFile(file) {
if (!file || !file.path) return;
// 审计修复 (Round 11 deep-fix P1-1):sync editor.isDirty() → state.isDirty。
// rename 的 dirty 检查在 FILE_NOT_FOUND 路径下出现,IME 期间跳过会丢 IME buffer。
syncDirtyFromEditor(state, editor);
const raw = await showPromptDialog({
title: '重命名',
message: '重命名为',
defaultValue: file.name,
confirmLabel: '重命名',
validate: validateFileName,
});
if (raw == null) return;
const newName = raw.trim();
if (!newName) {
showToast('文件名不能为空', 'warning');
return;
}
if (newName === file.name) return;
// Phase 2 audit 修复 (P1 BUG #3):设重命名守卫挡住 fs-watcher race 误触发 rescue。
// 主进程 renameWithRetry 累计 50+100+200=350ms 可能超过 fs-watcher 300ms 防抖
// 窗口,rename IPC 还没 resolve 时 fs-watcher 先把「A 已不在 + B 已出现」推到
// renderer,onFilesChanged 的 rescue 分支会把当前 dirty 内容救到
// `<A>-rescued-<stamp>.md`(内容 = editor 内容 = 与重命名后的 B 完全相同)→
// 孤儿文件。守卫:进 renameFile 前设 state.renamingPath = file.path,onFilesChanged
// rescue 分支遇此标记 + state.currentFile.path 一致 → 跳过 rescue。
// finally 中:先更新 state.currentFile.path,再清守卫(顺序很重要 —— 否则守卫清
// 在 path 更新之前,fs-watcher 又来一次看到旧 path 仍会被误判为删除)。
state.renamingPath = file.path;
try {
const result = await api.renameFile(file.path, newName);
if (!result || !result.ok) {
// 与 deleteFile 对齐:原文件已不在(被外部删过 / 同步盘撤回)→ 静默清视图,
// 否则编辑器里仍显示"刚被改名的旧路径文件",但磁盘上已无对应文件。
// 这里不抛"重命名失败"的红色 toast —— 因为失败的根本原因是文件没了,不是用户
// 输入的 newName 不合法(那种情况主进程会回 EEXIST / PATH_NOT_ALLOWED)。
if (result && result.error === 'FILE_NOT_FOUND') {
// Phase 2 audit 修复 (P1 BUG #5):rename 飞行期原文件被外部删除
// → dirty 内容不能直接 clearCurrentFile 丢。先取 editor.getContent()
// 救到 rescue 文件,再清。复用 onFilesChanged rescue 的 createFile 模式。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
const originalName = state.currentFile.name || 'untitled.md';
const base = originalName.replace(/\.md$/i, '');
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
const suffix = Math.random().toString(36).slice(2, 6);
const rescueName = `${base}-rescued-${stamp}-${suffix}.md`;
const rescueContent = (typeof editor.getContent === 'function') ? editor.getContent() : '';
// audit fix (Round 4 F6):与文件其它 IPC 调用对齐用 ctx.api(createFile /
// renameFile / deleteFile 都从 ctx 解构),不再直连 window.api。若未来
// IPC 桥被换 mock / 拦截器,rescue 路径与主路径走同一来源,行为一致。
api.createFile(rescueName, rescueContent).then((r) => {
if (r && r.ok) {
showToast(`原文件已被删除,未保存内容已救回到「${rescueName}」`, 'warning', 4500);
} else {
showToast(`原文件已被删除;未保存内容丢失(救回失败:${window.api.friendlyFsError(r?.code, r?.error)})`, 'error', 4500);
}
}).catch((e) => {
console.error('[file-ops] rename FILE_NOT_FOUND rescue IPC 异常:', e);
showToast('原文件已被删除;未保存内容丢失', 'error', 4500);
});
} else {
showToast('文件已不存在', 'warning');
}
if (state.currentFile && state.currentFile.path === file.path) {
clearCurrentFile();
}
return;
}
showToast(`重命名失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
if (state.currentFile && state.currentFile.path === file.path) {
const newMeta = {
path: result.path,
name: result.name,
mtimeMs: result.mtimeMs,
size: result.size, // P2-6:状态栏 size 也需要更新
};
// Phase N 修复:扩展名变化(foo.md → foo.txt)必须重算 currentFileIsMarkdown。
// 旧实现只更新 path/name,state.currentFileIsMarkdown 保留旧值 → viewer 仍
// 走 loadContent (markdown 渲染)、applyEditorMode 不重跑 → 工具栏 preview/split
// 按钮仍 enabled、用户可能在 .txt 文件里看到假 markdown 渲染。
const wasMd = state.currentFileIsMarkdown;
const isMd = isMarkdownFile(result.path);
state.currentFile = { ...state.currentFile, ...newMeta };
state.currentFileIsMarkdown = isMd;
// P2 fix:同步刷新 lastSavedMtimeMs —— fs-watcher 会因 rename 触发
// files-changed,setFiles 之后 onFilesChanged 会比对 state.files 的
// mtimeMs 与 lastSavedMtimeMs;如果不写后者,会被误判为「文件被外部修改」弹 rescue 提示。
state.lastSavedMtimeMs = result.mtimeMs;
// P2 fix:fs-watcher 触发 setFiles 时,新文件 path 还没出现在列表里
// (旧 path 已不在),file-list 会把 activePath 清空(见 setFiles 内 onFileRemoved 分支)。
// 主动 setActive 把高亮迁到新 path,避免「编辑器里有内容但侧栏看不到高亮」。
if (fileList && typeof fileList.setActive === 'function') {
try { fileList.setActive(result.path); } catch { /* 不让 setActive 异常阻断 rename 流程 */ }
}
// P1-5:viewer 也要同步 —— 笔记里的 <img src="./foo.png"> 是相对路径,
// viewer 用 viewer.currentFile.path 当 baseDir,路径不变就 404。
// setFileMeta 在 path 变化时自动重跑 resolveRelativeImages;
// path 没变则只刷状态栏。
viewer.setFileMeta(newMeta);
// 扩展名在 md ↔ 非 md 之间切换:viewer 内部 DOM 仍渲染旧内容。
// setFileMeta 只更新 metadata + 重跑相对图解析,**不**重渲染主体。
// 需要主动调 loadContent / showUnsupportedFile 切换 viewer 主区域;
// 再调 applyEditorMode 让工具栏按钮可用性 跟着对。
if (wasMd !== isMd) {
try {
const currentContent = editor && typeof editor.getContent === 'function'
? editor.getContent()
: '';
if (isMd) {
viewer.loadContent(state.currentFile, currentContent);
} else {
viewer.showUnsupportedFile(state.currentFile);
}
} catch (e) {
console.warn('[file-ops] rename viewer 切换失败:', e);
}
// applyEditorMode 会按 state.currentFileIsMarkdown 重置预览按钮 disabled、
// setReadOnly、updateStatusMode/Stats/Scroll。
if (typeof callbacks.applyEditorMode === 'function') {
callbacks.applyEditorMode(state.editorMode);
}
}
updateWindowTitle();
// audit fix (Round 9):rename 后通知 AI controller。理由同 discard_local:
// path 变了(content 也可能因扩展名切换而被 viewer 重渲染)—— AI 的
// pendingDiff 持有的 baseContent / forFilePath 与磁盘现状已经脱节,
// 不通知就会触发「AI 算 diff → 用户点应用 → setExternalContent 覆盖
// 刚保存的 rename 后内容」这条 stale apply 路径。AI controller 内部
// 自己负责 cancel + clearPendingDiff + requestGeneration++。
if (typeof callbacks.onFileChanged === 'function') {
try { callbacks.onFileChanged(state.currentFile); } catch (e) {
console.warn('[file-ops] rename 通知 AI 失败:', e);
}
}
}
showToast(`已重命名为:${result.name}`, 'success', 1500);
} finally {
// 顺序:先在上面把 state.currentFile.path 更新到 result.path,再清守卫。
// 如果在这里调 state.renamingPath = null 而 state.currentFile.path 还是旧值,
// 此时 fs-watcher 再来一次事件(很常见的二次触发)会看到 state.currentFile.path
// (旧) !== state.renamingPath (旧) → 不命中守卫 → 走 rescue 路径。
// 注意:本 finally 跑到这里,state.currentFile.path 已经被赋值成 result.path
// (上面 line 197 的 state.currentFile = { ...state.currentFile, ...newMeta }),
// 所以现在清守卫是安全的。
state.renamingPath = null;
}
}
/**
* 顶部「+」新建按钮:弹 prompt 取名 → 走 createFile → 自动打开新文件。
*
* Stage 8:把 state.currentDir 透传给 api.createFile,让新文件落在当前浏览的
* 子目录里(而不是永远落到数据根)。
*
* 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动
* 加上」—— 默认值已带 .md,用户可见 / 可改(直接改 .txt 等)。主进程
* resolveFileName 也对齐 resolveRenameName:用户输入什么就用什么,
* 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json
* 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。
*/
async function createNewFile() {
// 审计修复 (Round 11 deep-fix P1-1):entry sync 把 editor.isDirty() 真相同步给
// state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认,
// 用户的 CJK buffer 静默丢失。
syncDirtyFromEditor(state, editor);
if (state.isDirty) {
// 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动
const choice = await showConfirmDialog({
title: '新建文件',
message: '当前文件有未保存改动,新建文件将丢弃这些改动。继续?',
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '继续新建', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
// 用户已经确认丢弃当前改动。openFile 内部还会按 isDirty 再问一次「放弃改动 / 保存并打开」,
// 但用户在第一步已经表态「继续新建(丢弃)」,不应该再问一遍。
// audit fix:把 clearCurrentFile() 推迟到名字 prompt 成功 + 创建成功之后。
// 旧顺序:先 clearCurrentFile → 弹 prompt → 用户取消 → 当前文件已经清空了,
// 即便用户只是「改个名字」,也已经把刚才确认要丢弃的改动实际丢了,但还没真新建。
// 新顺序:保持 isDirty / currentFile 不变直到名字 OK + createFile 成功,
// 然后再清。如果用户在 prompt 阶段取消,编辑器内容原样保留。
}
const defaultName = `未命名-${formatStampForName(new Date())}.md`;
// Stage 8:在子目录里新建时,prompt 文案补一段「当前文件夹:xxx」让用户清楚新文件落哪。
// 根目录时维持原样。
// 用户反馈(2026-08-28):默认名直接带 .md 后缀(与重命名一致),用户想换成
// .txt / .json / 其他扩展名可直接编辑;不再「自动补」。
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
? state.breadcrumb.map((s) => s.name).join('/')
: '';
const message = breadcrumbTail
? `新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)\n当前文件夹:${breadcrumbTail}`
: '新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)';
const raw = await showPromptDialog({
title: '新建笔记',
message,
defaultValue: defaultName,
confirmLabel: '创建',
validate: validateFileName,
});
if (raw == null) return;
const name = raw.trim();
if (!name) {
showToast('文件名不能为空', 'warning');
return;
}
// Stage 8:把当前目录绝对路径传给主进程;主进程负责 auto-mkdir(用户主动在该目录下新建)。
const result = await api.createFile(name, { dir: state.currentDir || '' });
if (!result || !result.ok) {
showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
// 名字 prompt + IPC 都成功了才清空当前文件(覆盖上面推迟的 clearCurrentFile)
if (state.isDirty) clearCurrentFile();
showToast(`已创建:${result.name}`, 'success', 1500);
// 主动打开新文件(不等 files:changed)
await openFile({
path: result.path,
name: result.name,
size: result.size,
mtimeMs: result.mtimeMs,
});
}
/**
* 文件列表右键菜单:重命名 / 在文件夹中显示 / 删除
*
* Stage 8:folder 条目上隐藏「重命名」—— file:rename 主进程逻辑强制 .md 后缀,
* 对目录并不合适;文件夹的批量整理通过 shell 文件管理器(系统级)操作更直接,
* 这里至少保留删除 + 在文件夹中显示。
*/
function showFileListContextMenu(entry, x, y) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) {
items.push({ label: '重命名', value: 'rename' });
}
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
contextMenu.show({
x, y, items,
onSelect: (value) => {
if (value === 'rename') renameCurrentOrFile(entry);
else if (value === 'reveal') revealFile(entry);
else if (value === 'delete') deleteFile(entry);
},
});
}
return {
showFileListContextMenu,
createNewFile,
// 暴露给测试 / 调试用
renameCurrentOrFile,
deleteFile,
revealFile,
formatStampForName,
};
}

173
src/focus-trap.js Normal file
View File

@@ -0,0 +1,173 @@
// 焦点陷阱 —— 通用最小可工作版
//
// 解决两件事:
// 1. Tab / Shift+Tab 在容器内循环,不外溢到背景 DOM
// 2. 关闭时把焦点还给打开前的元素
//
// 不做(避免过度工程):
// - 自动找"第一个 focusable"作为初始焦点 —— 由调用方传入 initialFocus 更明确
// - 屏幕阅读器特殊处理 —— aria-modal + aria-hidden 已交给调用方
// - 焦点恢复时滚屏校正 —— focus({ preventScroll: true }) 即可
//
// 调用约定:
// const trap = createFocusTrap(overlay, {
// initialFocus: overlay.querySelector('input, button, [tabindex]:not([tabindex="-1"])'),
// signal: abortController.signal, // 可选:signal abort 时自动 dispose
// });
// ...
// trap.dispose(); // 把焦点还给 prevFocus
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function createFocusTrap(container, { initialFocus = null, signal = null, fallback = null } = {}) {
if (!container) throw new Error('createFocusTrap: container 必填');
// audit fix:原条件只排除 body,但 <html>(documentElement)也是
// document.activeElement 的常见值(焦点漂移到根节点 —— 例如布局 reflow
// 期间、点击非可聚焦区域后)。<html>.focus() 是合法但无视觉焦点的
// no-op,导致 dispose 后屏幕阅读器 / 键盘用户的虚拟焦点被困在已关闭
// 的模态内。这里把 documentElement / body 都视为「无效 prevFocus」,
// 返回 null;dispose 会直接跳过 focus 调用,焦点自然落到 body 上。
const ae = document.activeElement;
const prevFocus = (ae && ae !== document.body && ae !== document.documentElement
&& typeof ae.focus === 'function') ? ae : null;
// 容器自身需要能接收焦点,否则 Tab 从末尾跳到第一个时容器不会被激活
const hadTabindex = container.hasAttribute('tabindex');
if (!hadTabindex) {
container.setAttribute('tabindex', '-1');
}
/** @returns {HTMLElement[]} */
function getFocusable() {
// H1 fix (audit):原 filter 用 `el.offsetParent !== null` 判断可见性,
// 但 `position: fixed` 元素的 offsetParent === null,会被误判成「不可见」
// 从 Tab 序列里剔除。模态里有 sticky 按钮 / 浮动操作按钮(如未来加的
// 「在此打开文件夹」),Tab 会跳过它们到末尾后跳回首项,无法到达。
// 用 `getClientRects().length > 0` 兜住 fixed / sticky,且对 display:none
// 仍然返回空(hidden 元素没有 layout box)。已聚焦的 fixed 元素保留。
//
// 审计修复 (Round 11):还要排除以下三类元素:
// - visibility: hidden —— 仍有 client rects(filter 里已放过),但 .focus()
// 是 silent no-op;Tab 序列走到这里 → first.focus() 落在 no-op 元素上 →
// 「Tab 死掉」的假象
// - inert 子树 —— W3C 新标准,把子树标记为不可交互;closest('[inert]')
// 命中即剔除
// - aria-hidden=true 子树 —— 屏幕阅读器跳过;但键盘 Tab 仍可能命中,
// 排除防止 Tab 看着「消失」
// contenteditable 显式加入选择器,CM 编辑器嵌入 modal 时能正常 Tab 进。
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR + ',[contenteditable]:not([contenteditable="false"])'))
.filter((el) => {
if (el.getClientRects().length === 0 && el !== document.activeElement) return false;
if (el.closest && el.closest('[inert],[aria-hidden="true"]')) return false;
// visibility:hidden 元素的 clientWidth/Height 仍可能 > 0,但 getComputedStyle
// 拿到 visibility === 'hidden'。已经聚焦的元素(activeElement)保留——可能
// 是「用户主动聚焦后外部样式改了 visibility」的边角,dispose 时回退路径不应
// 把已聚焦元素从序列里踢出。
if (el !== document.activeElement) {
const cs = (typeof window !== 'undefined' && window.getComputedStyle) ? window.getComputedStyle(el) : null;
if (cs && cs.visibility === 'hidden') return false;
}
return true;
});
}
function onKeydown(e) {
// audit fix (Phase O-L14):Tab 也守 IME 合成。
// 与 shortcuts.js:84 / markdown-editor.js 风格一致:CJK 拼音输入中途按 Tab
// 选候选词(Windows IME 习惯),focus-trap 不能把焦点甩到下一个按钮。
// 当前 modal 没有 IME 重输入控件(settings / confirm / prompt 都是普通
// input),潜在风险。但保持一致性:所有 document-level keydown listener
// 都守 isComposing / keyCode=229。
if (e.isComposing || e.keyCode === 229) return;
if (e.key !== 'Tab') return;
const items = getFocusable();
if (items.length === 0) {
// 没有可聚焦元素:把焦点留在容器上,避免跳出去
e.preventDefault();
container.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || !container.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last || !container.contains(active)) {
e.preventDefault();
first.focus();
}
}
}
container.addEventListener('keydown', onKeydown);
let disposed = false;
function dispose() {
if (disposed) return;
disposed = true;
container.removeEventListener('keydown', onKeydown);
// audit fix (renderer-M10):只在是我们自己注入的 tabindex 时移除,
// 宿主原有值(如 HMR 复用场景下节点本身带 tabindex)原样保留。
if (!hadTabindex) {
container.removeAttribute('tabindex');
}
if (prevFocus && typeof prevFocus.focus === 'function') {
// 审计修复 (Round 11):dispose 时重验 isConnected。
// prevFocus 在 capture 阶段(第 38-40 行)只检查 !== body/rootElement,
// 但 modal 期间 prevFocus 可能被外部重渲染 / 撕下 DOM —— 例如:
// - file-list 右键菜单 → 「重命名」 → 弹 prompt-dialog;
// 菜单 close 时整块被 unmount,prevFocus 已 detached,
// .focus() 是 silent no-op,焦点落到 <body>,键盘用户失位。
// - 文件列表 items 在 files:changed 事件里重建,prevFocus 同样 detached。
// 重新检查 isConnected;连接不上时回退到稳定锚点(caller 提供
// 的 fallback)—— 而不是让焦点散落到 <body>。
//
// audit fix (Settings P3 / focus-trap fallback lift):原来这里
// 硬编码 `document.getElementById('file-list')` 作为兜底 —— 把
// 「renderer 侧具体 DOM 节点」写进通用 focus-trap,跨上下文错位:
// - settings-dialog 关掉 → 焦点跳到 #file-list,语义错位
// (用户在设置里改东西,期望焦点回设置前的位置)
// - confirm / prompt / 未来 AI 弹窗同样问题
// - 通用包里写死具体 id,一旦 #file-list 改名/删名静默退化到 <body>
// 现在让 caller 通过 fallback 选项传一个语义匹配的节点;未传则按
// fallback 节点 → document.body 的顺序找第一个仍 connected 的。
// 之前 modal.js 调用点没传 fallback,下面 mountModal 那侧会传入
// 一个 caller 级别的稳定锚点(modal.options.fallbackFocus)。
let restoreTarget = prevFocus;
if (!prevFocus.isConnected) {
const candidates = [fallback, document.body].filter(Boolean);
restoreTarget = candidates.find((el) => {
try { return el && typeof el.isConnected === 'boolean' && el.isConnected; }
catch { return false; }
}) || document.body;
}
try { restoreTarget.focus({ preventScroll: true }); } catch { /* ignore */ }
}
}
if (signal) {
if (signal.aborted) dispose();
else signal.addEventListener('abort', dispose, { once: true });
}
// 初始焦点:异步让出渲染时间,避免被外层 setTimeout(0) 抢走
queueMicrotask(() => {
if (disposed) return;
const target = initialFocus || getFocusable()[0] || container;
try { target.focus({ preventScroll: true }); } catch { /* ignore */ }
});
return { dispose };
}

445
src/markdown-editor.js Normal file
View File

@@ -0,0 +1,445 @@
// Markdown 编辑器(CodeMirror 6 包装)
// 职责:装载文件、跟踪脏状态、暴露 save 回调。
//
// 不负责:文件 IO(走 app.js)、确认对话框(走 confirm-dialog.js)。
//
// 与 markdown-viewer.js 的关系:viewer 只关心「渲染这一段字符串」,
// editor 只关心「让用户编辑这一段字符串」;二者由 app.js 协调。
import { EditorState, Prec, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { getTextStats } from './stats.js';
import { pickEditorTheme } from './editor-theme.js';
import { createDisposable } from './component.js';
/**
* Markdown 编辑器(CodeMirror 6 包装)
* 职责:装载文件、跟踪脏状态、暴露 save 回调。
*
* 不负责:文件 IO(走 app.js)、确认对话框(走 confirm-dialog.js)、
* 主题定义(走 editor-theme.js)。
*
* 与 markdown-viewer.js 的关系:viewer 只关心「渲染这一段字符串」,
* editor 只关心「让用户编辑这一段字符串」;二者由 app.js 协调。
*/
export class MarkdownEditor {
/**
* @param {object} options
* @param {HTMLElement} options.element - 容器 div(需有 height: 100%)
* @param {() => void} options.onChange - 内容变化回调(用于脏状态 UI)
* @param {() => void} options.onSave - Ctrl/Cmd+S 触发(也供 app.js 直接调用)
* @param {(progress:number) => void} [options.onScroll] - 滚动/光标变化回调 (0..1),用于双栏同步滚动
*/
constructor({ element, onChange, onSave, onScroll }) {
this.element = element;
this.onChangeCallback = onChange;
this.onSaveCallback = onSave;
this.onScrollCallback = onScroll || null;
this.view = null;
this.lastSavedDoc = '';
this.editableCompartment = new Compartment();
this.themeCompartment = new Compartment();
// audit fix (renderer-M4):滚动/光标 geometryChanged 在 split 模式下
// 会被 app.js 同步调到 viewer.setScrollProgress;getScrollProgress 读
// scrollHeight/clientHeight 强制 layout flush,setScrollProgress 又写
// scrollTop 触发再次 layout —— 每个 keystroke + 移动光标都走一遍 N 次
// layout 计算,长文档能感知到 jank。用 rAF 把同帧内的多次触发合并到
// 一次,rAF 回调里只做一次 getScrollProgress(reader 与 writer 之间也
// 是同一帧,避免交错布局)。
this._scrollFrame = null;
// H1 fix (audit P0):统一 dispose 入口。MarkdownEditor 不直接挂 DOM listener
// (CodeMirror 自己处理),但需要暴露 dispose() 让 app.js 在切数据目录 / 重启
// 窗口 / HMR 时显式释放。createDisposable 也提供 addAbort 注册 _destroyView
// 之外的清理(如 rAF 取消、Compartment 引用置空)。dispose() 幂等,可重复调用。
createDisposable(this);
// 兜底清理:即便外部忘记调 dispose,rAF 取消 + view.destroy 仍会执行。
// 注意:createDisposable 会在实例上挂 this.dispose —— 不能在 class 原型上
// 另写同名的 dispose() 覆盖它。所有清理逻辑都通过 addAbort 注册,dispose()
// 触发 abort 时统一执行。
this.addAbort(() => {
this._destroyView();
this.lastSavedDoc = '';
// 解除 callback 闭包对 viewer / 业务对象的引用,便于 GC。
this.onChangeCallback = null;
this.onSaveCallback = null;
this.onScrollCallback = null;
// audit fix (Round 7 LOW):补齐 dispose 引用清理,与 onChangeCallback 等
// 对称。_composing 标志若残留 true,下次 new MarkdownEditor 拿旧实例时会
// 误判 IME(虽然 module-scope 的 editor 实例永不释放,但显式置 false
// 让 _destroyView 路径上的状态机更易预测)。compartment 显式置 null 释放
// 引用。_onComposition* 函数引用故意保留(顶部 view null 守卫兜底)——
// dispose 后极端时序再 fire 这些 handler 仍然 noop,不会抛 TypeError。
this._composing = false;
this.editableCompartment = null;
this.themeCompartment = null;
});
}
/**
* 当前主题(dark / light)—— 决定是否套用 oneDark
*/
get currentTheme() {
return document.body.dataset.theme === 'light' ? 'light' : 'dark';
}
/**
* 是否处于 IME 合成状态。CM6 不暴露 composing API;通过 contentDOM
* compositionstart/end listener 跟踪。app.js 在 IME 期间要跳过:
* - auto-save(save() 写入 IME-buffer 中的未确认字符)
* - AI 应用(applyUserChange 在合成期会破坏 IME buffer)
* 暴露 getter 而不是让外部读 _composing 下划线字段。
* @returns {boolean}
*/
isComposing() {
return this._composing === true;
}
/**
* 加载文件内容到编辑器。多次调用会销毁旧 view。
* @param {{path:string,name:string}} file
* @param {string} content
*/
loadFile(file, content) {
// audit fix (Round 7 MED):element 早失败。CodeMirror 内部 parent.appendChild
// 会因 null/undefined 抛 TypeError,把整个 bootstrap 拖到 unhandled rejection。
// 显式校验:调用方若传错,loadFile 直接抛可读错误,app.js 顶层 catch 能定位。
if (!this.element || typeof this.element.appendChild !== 'function') {
throw new Error('MarkdownEditor: element required (must be a DOM node)');
}
this._destroyView();
this.lastSavedDoc = content || '';
const state = EditorState.create({
doc: this.lastSavedDoc,
extensions: [
EditorView.lineWrapping,
basicSetup,
markdown(),
this.themeCompartment.of(pickEditorTheme(this.currentTheme)),
this.editableCompartment.of(EditorView.editable.of(true)),
Prec.highest(keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => {
if (this.onSaveCallback) this.onSaveCallback();
return true;
},
},
])),
EditorView.updateListener.of((update) => {
// audit fix (K2-H2):CJK IME 合成期间,CM6 把每个临时字符 commit 成
// 独立 transaction(userEvent: 'input.type.compose')—— 一个 20 字的
// 中文段落会触发 20 次 updateListener,每次 docChanged=true 都跑
// onChange 全链路:state.isDirty 立即翻 true(用户还没打完就显示
// 脏态)、save 按钮进 is-dirty 提示态、scheduleAutoSave 反复 clearTimeout、
// scheduleLivePreview 200ms 后 viewer.loadContentLive 整页 innerHTML
// 重建。合成中的 IME 字符不会真的写盘,对外只 commit 一次(compositionend)
// —— 跳过中间过程,让脏态 / live preview / auto-save 在 compositionend
// 时一次性 flush。
// scroll(geometryChanged)不依赖 docChanged,IME 期间用户也可能滚动
// 列表,跟之前一样保留。
const isComposing = update.transactions.some((t) => t.isUserEvent('input.type.compose'));
if (update.docChanged && !isComposing && this.onChangeCallback) {
this.onChangeCallback();
}
// P1-10:只把「真实滚动」转发给 onScroll。
// selectionSet(光标移动)也走到了这里 —— 但 app.js 的 onScroll 只是
// viewer.setScrollProgress(...) 同步右侧预览位置,光标移动跟预览无关,
// 不应该触发。getScrollProgress 会读 scrollHeight/clientHeight,强制
// layout flush,10K 行文档下每个 keystroke + 移动光标都做几次 N 次
// layout 计算,体感明显。
if (update.geometryChanged && this.onScrollCallback) {
// audit fix (renderer-M4):geometryChanged 在 split 模式下会被高
// 频触发,rAF 合并到一帧一次。rAF 在浏览器不可见标签页里会自然暂停,
// 也避免背景 tab 里持续 layout flush。
if (this._scrollFrame !== null) {
cancelAnimationFrame(this._scrollFrame);
}
this._scrollFrame = requestAnimationFrame(() => {
this._scrollFrame = null;
// view 已经在 _destroyView 走完时被置 null,rAF 回调里再读会抛
// getScrollProgress 已经做 null 检查,但显式守一遍更稳。
if (!this.view || !this.onScrollCallback) return;
this.onScrollCallback(this.getScrollProgress());
});
}
}),
],
});
this.view = new EditorView({
state,
parent: this.element,
});
// audit fix (Phase O-M1):跟踪 IME 合成状态。
// CM6 没有公开 composing API(这是 contenteditable 的概念),最准确的方式
// 是直接在 contentDOM 上挂 compositionstart/compositionend listener。
// applyUserChange(AI 应用 diff)合成期间调用会摧毁 IME buffer,让 AI 控
// 制器在 catch 里给用户「请稍后再试」提示。
// listener 用 addAbort 注册,dispose 时统一解绑。
//
// audit fix (IME-dirty-flush):compositionend 后补发 onChange。
// updateListener 里 `!isComposing` 守卫在 IME 合成期间屏蔽 onChange
// (避免中文每字触发一次 scheduleLivePreview / scheduleAutoSave)。
// 但 compositionend 后 CM6 不一定再产生新 transaction —— 此时 onChange
// 永远不触发,state.isDirty 保持 false,save() 走 !isDirty 早退,
// 用户看到的中文已上屏但磁盘没写入。修复:compositionend 时主动比较
// doc 与 lastSavedDoc,不同则补发 onChange 让 app 层正确翻脏态。
this._composing = false;
// audit fix (Round 7):三个 IME handler 都先守 view 存在,dispose 后
// _destroyView 把 view 置 null,再 fire 这些 handler(极端时序)不会抛
// TypeError 而是 noop。原 onChange 路径里也有 `this.view &&` 守卫,这次
// 提到函数顶部,让整个 dispose-after-fire 路径完全幂等。
this._onCompositionStart = () => {
if (!this.view) return;
this._composing = true;
};
this._onCompositionEnd = () => {
if (!this.view) return;
this._composing = false;
// IME 提交后补发 onChange:doc 已变但 updateListener 在合成期没调过
// onChange → state.isDirty 还是 false。这里主动调一次让 app 翻脏。
// 防御性校验:view 可能已被 dispose、callback 可能已解绑。
if (this.onChangeCallback) {
const currentDoc = this.view.state.doc.toString();
if (currentDoc !== this.lastSavedDoc) {
this.onChangeCallback();
}
}
};
// audit fix (Round 7 HIGH):compositioncancel listener。
// 部分 OS / IME 实现(如 macOS 日语 IME 切换、Windows 部分第三方输入法)
// 在用户按 Esc 取消 / 外部抢焦点 / IME 引擎切换时只发 compositioncancel
// 不发 compositionend —— _composing 会永远停在 true,所有 dispatch
// (applyUserChange / setExternalContent)抛 EDITOR_COMPOSING,编辑器
// 永久锁死直到用户 reload view。处理同 compositionend:清标记 + 补 onChange。
this._onCompositionCancel = () => {
if (!this.view) return;
this._composing = false;
if (this.onChangeCallback) {
const currentDoc = this.view.state.doc.toString();
if (currentDoc !== this.lastSavedDoc) {
this.onChangeCallback();
}
}
};
this.view.contentDOM.addEventListener('compositionstart', this._onCompositionStart);
this.view.contentDOM.addEventListener('compositionend', this._onCompositionEnd);
this.view.contentDOM.addEventListener('compositioncancel', this._onCompositionCancel);
this.addAbort(() => {
if (!this.view || !this.view.contentDOM) return;
this.view.contentDOM.removeEventListener('compositionstart', this._onCompositionStart);
this.view.contentDOM.removeEventListener('compositionend', this._onCompositionEnd);
this.view.contentDOM.removeEventListener('compositioncancel', this._onCompositionCancel);
});
}
/**
* 切换编辑器主题(不重建 view)
* @param {'dark'|'light'} theme
*/
setTheme(theme) {
if (!this.view) return;
const ext = pickEditorTheme(theme);
this.view.dispatch({
effects: this.themeCompartment.reconfigure(ext),
});
}
/**
* 卸载当前编辑器(不写盘)
*/
unload() {
this._destroyView();
this.lastSavedDoc = '';
// Phase N-2 修复(关键 bug · 保存按钮失灵):
// K2-L10 的旧实现会在这里清掉 onChange/onSave/onScroll 三个 callback。
// 后果:clearCurrentFile() → unload() 把 callback 置空 → 下次 openFile
// 走 editor.loadFile() 时只重建 view,**不重建 callback** → 用户的
// 编辑不再触发 state.isDirty 翻转 → 保存按钮永远不亮 → 用户以为保存
// 按钮坏了。
//
// 触发场景:当前打开的文件被外部删除 → applyEntries 调 clearCurrentFile
// → 卸载旧 view 并清 callback → 用户再开新文件 → loadFile 只重建 view
// → callback 仍是 null → 编辑器从此对所有用户输入静默忽略。
//
// K2-L10 的原意(HMR/切数据目录/Ctrl+R 不挂旧闭包):这一层不该由
// unload() 兜底,应该由 dispose() 兜底(pagehide 清理在 app.js:3497
// 已正确调 editor.dispose()),dispose() 走 addAbort 注册的清理路径
// 会把 callback 置 null。unload() 只清 view 状态,不动 callback。
//
// 关于 callback 闭包反向持有 viewer/state/ai 的"内存延长"顾虑 —— editor
// 实例本身是 module-scope 的(app.js:475 const editor = new MarkdownEditor(...)),
// 永不释放;callback 持有那几个引用等价于 editor 持有那几个引用,不存在
// "延长 lifecycle"的差异。让 callback 跟 editor 一起活到 dispose()。
}
/**
* 取当前编辑器内容(字符串)
*/
getContent() {
if (!this.view) return '';
return this.view.state.doc.toString();
}
/**
* 是否脏(未保存改动)
*/
isDirty() {
if (!this.view) return false;
return this.view.state.doc.toString() !== this.lastSavedDoc;
}
/**
* 标记当前内容为「已保存」基线。下次 isDirty() 比较的就是这次保存的版本。
*/
markSaved() {
if (!this.view) return;
this.lastSavedDoc = this.view.state.doc.toString();
}
/**
* 用外部内容替换当前编辑器内容(例如外部修改覆盖本地)。
* 同时更新 lastSavedDoc —— 因为这是从磁盘来的「最新权威」。
*
* 注意:必须先更新 lastSavedDoc 再 dispatch,否则 updateListener 会同步触发 onChange,
* 此时 lastSavedDoc 仍是旧值,isDirty() 会错误返回 true。
*
* audit fix (K2-M4):dispatch 前快照当前 selection,dispatch 时回填到
* clamp 后的位置 —— CM6 默认把光标落到新 doc末尾,若用户正读到第 50 行做
* diff 标注,外部一次保存就把编辑器滚到底部,等于打断阅读。
*/
setExternalContent(content) {
if (!this.view) return;
// audit fix (Round 4 F2):IME 合成期拒绝 setExternalContent。
// fs-watcher 在 _composing=true 期间推「外部修改」→ view.dispatch 全量替换
// doc 会摧毁 IME 合成 buffer,已输入的拼音字符消失。applyUserChange 已有同样
// 守卫,setExternalContent 同性质入口行为必须对齐。抛 EDITOR_COMPOSING 让
// 调用方 catch 后给用户「请稍后再试」提示或推迟到 compositionend 后再 dispatch。
if (this._composing) {
throw new Error('EDITOR_COMPOSING');
}
const sel = this.view.state.selection.main;
this.lastSavedDoc = content || '';
const insert = content || '';
const newLen = insert.length;
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert },
selection: {
anchor: Math.min(sel.anchor, newLen),
head: Math.min(sel.head, newLen),
},
});
}
/**
* 用新内容替换当前编辑器内容,并把这次替换标记为"未保存改动"。
* 与 setExternalContent 的区别:lastSavedDoc 不跟着更新,
* 替换后 isDirty() 仍返回 true(自动保存 / Ctrl+S 能正确触发)。
*
* 用于 AI 修改应用:用户能 Ctrl+Z 撤销 AI 的改动、自动保存会落盘、
* 保存按钮进入 is-dirty 提醒态。
*
* userEvent: 'input.fromAI' 让 CodeMirror 把它当作一次用户输入(影响 undo history)。
* 单次 dispatch 完整替换(而不是 splice),所以 undo 一步就回到旧内容。
*/
applyUserChange(content) {
if (!this.view) return;
// audit fix (Phase O-M1):IME 合成期间拒绝 applyUserChange。
// AI 控制器(chat dock 自动 apply / 用户鼠标误点 chat)调用本方法时,
// 若用户正在 CJK 拼音输入,view.dispatch 全量替换 doc 会摧毁 IME 合成
// buffer,已输入的拼音字符消失。抛特定 message 让调用方 catch 后给用户
// 「请稍后再试」提示(ai-controller.js 已 try/catch + 按 message 区分)。
if (this._composing) {
throw new Error('EDITOR_COMPOSING');
}
// audit fix (K2-M3):dispatch 前快照 selection.main(AI 应用 region/all
// 整页替换后,CM6 默认把光标落到新 doc 末尾)。clamp 到新 doc 内,至少
// 保留用户下次编辑起点(接受 AI 改动后想继续写,不至于被甩到文末)。
const sel = this.view.state.selection.main;
const insert = content || '';
const newLen = insert.length;
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert },
userEvent: 'input.fromAI',
selection: {
anchor: Math.min(sel.anchor, newLen),
head: Math.min(sel.head, newLen),
},
});
}
// L11 fix (audit):原参数名 `enabled` 是双重否定陷阱 —— 调用方
// setReadOnly(true) 实际是「启用只读」,跟「enable writing」直觉反着。
// 改名 `readOnly` 后 setReadOnly(true) = 只读,setReadOnly(false) = 可写。
// CM6 内部 EditorView.editable.of(true) 表示「可编辑」,所以仍然要取反。
setReadOnly(readOnly) {
if (!this.view) return;
this.view.dispatch({
effects: this.editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
});
}
focus() {
if (this.view) this.view.focus();
}
/**
* 编辑器的滚动进度(0..1)
*
* 用滚动视口顶部在文档中的位置(即「已滚过的距离 / 总可滚距离」)作为比例,
* 与 viewer 的 getScrollProgress() 口径一致,双栏同步时直接套用即可。
*
* 如果文档没溢出(max<=0),返回 0 —— 避免 NaN。
* @returns {number}
*/
getScrollProgress() {
if (!this.view) return 0;
const scroller = this.view.scrollDOM;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return 0;
return Math.min(1, Math.max(0, scroller.scrollTop / max));
}
/**
* 把编辑器滚动到指定比例(0..1)。供双栏反向同步(viewer → editor)。
* 与 viewer.setScrollProgress() 是一对。
* @param {number} progress
*/
setScrollProgress(progress) {
if (!this.view) return;
const scroller = this.view.scrollDOM;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
const target = Math.min(1, Math.max(0, progress)) * max;
scroller.scrollTop = target;
}
/**
* 当前编辑器的字数统计(CJK 友好,统一走 stats.js)
* 行数基于源文本非空行数,与 viewer 一致 —— 切换预览/编辑时不会跳变。
* @returns {{chars:number, words:number, lines:number}}
*/
getStats() {
if (!this.view) return { chars: 0, words: 0, lines: 1 };
return getTextStats(this.view.state.doc.toString());
}
_destroyView() {
// audit fix (renderer-M4):先取消可能挂着的 rAF —— 否则 destroy 后 rAF
// 回调里读 this.view / this.onScrollCallback 还能命中内存中尚未 GC 的
// 引用,触发到旧组件实例的回调链。
if (this._scrollFrame !== null) {
cancelAnimationFrame(this._scrollFrame);
this._scrollFrame = null;
}
if (this.view) {
this.view.destroy();
this.view = null;
}
}
}

736
src/markdown-viewer.js Normal file
View File

@@ -0,0 +1,736 @@
// Markdown 阅读视图组件
// 职责:把 markdown 字符串渲染成 HTML、更新状态栏、拦截链接点击。
//
// 与 markdown-editor.js 的关系:viewer 只关心「显示」。
// 加载文件的 dirty 检查、IO 协调全部交给 app.js。
import { formatSize, formatDate, dirnameOf, resolvePath, toFileUrl, safeRenderMarkdown, isPathInside } from './utils.js';
import { getTextStats } from './stats.js';
import { createDisposable } from './component.js';
/**
* 转义要放进 [id="..."] 属性选择器里的值。
* 中文 / 数字开头的 id 直接拼 '#' 会是非法选择器,属性选择器最省事,
* 只需要处理反斜杠和双引号。
*/
function cssAttrEscape(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* 渲染 markdown 字符串为安全的 HTML
* - 委托给 utils.safeRenderMarkdown:marked + DOMPurify 实际初始化在 preload.js 里完成
* - 失败 / 不可用时降级为转义后的 <pre>(永远不返回未转义的原文)
*/
function renderMarkdown(markdown) {
if (!markdown || typeof markdown !== 'string') return '';
return safeRenderMarkdown(markdown);
}
export class MarkdownViewer {
/**
* @param {object} options
* @param {HTMLElement} options.bodyElement - <article> 渲染容器
* @param {HTMLElement} options.emptyElement - 空状态元素
* @param {HTMLElement} options.statusPath - 状态栏路径
* @param {HTMLElement} options.statusMeta - 状态栏元信息
* @param {(url:string) => void|Promise<void>} options.onExternalLink - 外部链接点击回调
* @param {() => string|Promise<string>} [options.getDataDir] - 返回当前数据目录绝对路径
* 异步 callback;resolveRelativeImages 用它做安全边界检查(audit C3)。
* 不传则跳过越界检查,回退到「只挡 ../.. 越界字符串」的原行为。
* @param {() => string|null} [options.getCurrentDir] - 返回当前浏览目录的绝对路径
* 没打开文件时状态栏用它替代「—」显示当前文件夹位置(用户从路径就能定位到根/子目录)。
* app.js 在 scanDir / loadFileList 完成后调 refreshCurrentDir() 让 chip 跟随刷新。
*/
constructor({ bodyElement, emptyElement, statusPath, statusMeta, onExternalLink, getDataDir, getCurrentDir }) {
this.bodyElement = bodyElement;
this.emptyElement = emptyElement;
this.statusPath = statusPath;
this.statusMeta = statusMeta;
this.onExternalLink = onExternalLink;
// 数据目录提供者 —— 用 callback 而非缓存值,因为 dataDir 会随用户切换设置而变。
this.getDataDir = typeof getDataDir === 'function' ? getDataDir : null;
// 当前浏览目录提供者 —— 仅用于无文件时状态栏路径展示。
this.getCurrentDir = typeof getCurrentDir === 'function' ? getCurrentDir : null;
this.currentFile = null;
this.currentContent = '';
this.currentHeadingId = null; // 当前在视口里最高可见的 heading id
this.onHeadingChangeCallbacks = new Set();
// 当前文档所有 heading 的快速查表(id → {level, text, id}),
// 由 _rebuildHeadingObserver 同步构建;状态栏 heading chip 用它把
// currentHeadingId 还原成可读文本。
this._headingMap = new Map();
// IntersectionObserver:跟踪"当前在视口顶部的 heading"
this._headingObserver = null;
// 每个打开过的文件保留一份滚动位置。
// 上限 SCROLL_POSITION_CACHE_LIMIT = 200:超出后按插入顺序淘汰最旧未访问的条目,
// 防止打开几千个文件后 Map 无限增长(每个 entry ≈ 8 字节 key + 8 字节 number)。
// Map 保留插入顺序;重新访问某文件时先 delete 再 set,等价于移到队尾。
this.scrollPositions = new Map();
this.SCROLL_POSITION_CACHE_LIMIT = 200;
// 跨文件竞态守卫:resolveRelativeImages 是 fire-and-forget 的 async,
// A→B 快速切换文件时,A 的回调可能在 B 已经接管 DOM 后才 resume。
// 用单调递增的 generation token,每次 resolveRelativeImages 入口自增,
// 关键 await 之后再比一次 —— 不等就 return,避免 A 的解析污染 B 的图片。
this._resolveGen = 0;
// 缓存空态元素初始 innerHTML —— showUnsupportedFile 会临时改写它,
// 下一次 loadContent/clear 再还原回 index.html 里的默认文案(避免
// 「上次打开 .txt 残留的'不支持预览'文字」污染下次空态)。
this._defaultEmptyHTML = emptyElement ? emptyElement.innerHTML : '';
// 统一 lifecycle:dispose() 一次性 unbind 所有 listener。
createDisposable(this);
// audit fix (Phase L1-M1):onHeadingChange 回调 Set
// 不在 listener 信号路径上 —— 持有 app.js / state / editor / ai 等大闭包,
// dispose 时不显式 clear 就会一直挂到 pagehide。HMR / 调试路径下 viewer
// 替换后旧回调还能触发,让新组件的 state 被污染。abort 时一次性清空。
this.addAbort(() => {
this.onHeadingChangeCallbacks.clear();
// audit fix (Round 12 P3):_headingObserver 是 IntersectionObserver,
// _rebuildHeadingObserver / clear / showUnsupportedFile 三处各自
// disconnect,但 dispose 路径没有兜底。detach 后的 heading 节点
// 被 IntersectionObserver 持有 → Detached HTMLHeadingElement 不被 GC
// 直到 observer disconnect。
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
});
// 拦截链接点击:所有 .markdown-body 内的 <a> 都通过 onExternalLink 走系统默认浏览器
if (this.bodyElement) {
this.bodyElement.addEventListener('click', (e) => {
const anchor = e.target.closest('a[href]');
if (!anchor) return;
const href = anchor.getAttribute('href');
// 统一先 preventDefault:空 href ('') / 锚点 / 任意相对路径,
// 都不让浏览器按默认行为跑(空 href 不阻止会让 Electron 重载页面)。
e.preventDefault();
if (!href) return; // 空 href:什么都不做(合法占位,不触发系统行为)
// 文内锚点(#xxx):自己滚动,不改地址栏 hash。
// marked 会把 href 里的中文百分号编码,而 id 是原文,必须先解码再匹配。
if (href.startsWith('#')) {
this.scrollToAnchor(href.slice(1));
return;
}
if (this.onExternalLink) {
// audit fix (Round 12 P2):onExternalLink 在调用方是 async 函数
// (src/app.js:419 走 openFile + openExternal IPC),同步调不会等
// promise,IPC 抛 throw(非 ok=false)会变成 unhandledRejection。
// 用户点击外链没反应、只能去主进程 console 翻日志。在 viewer
// 这一层 catch 住所有异常 + 打 console + 不影响 click 后续。
// 调用方已用 {ok,message} envelope 走友好提示,这里只兜
// 「真 throw」这一条路径。
try {
const ret = this.onExternalLink(href);
if (ret && typeof ret.catch === 'function') {
ret.catch((err) => {
console.error('[markdown-viewer] onExternalLink promise rejected:', err);
});
}
} catch (err) {
console.error('[markdown-viewer] onExternalLink threw synchronously:', err);
}
}
}, { signal: this.signal });
}
}
/**
* 滚动到文内锚点。
* @param {string} rawHash - 不含 '#' 的片段,可能是百分号编码的
* @returns {boolean} 是否找到目标
*/
scrollToAnchor(rawHash) {
if (!rawHash || !this.bodyElement) return false;
let id = rawHash;
try {
id = decodeURIComponent(rawHash);
} catch {
// 非法编码就按原样匹配
}
// 用属性选择器而不是 '#'+id:中文/数字开头的 id 在 CSS 选择器里需要转义
const target = this.bodyElement.querySelector(`[id="${cssAttrEscape(id)}"]`)
|| this.bodyElement.querySelector(`[id="${cssAttrEscape(rawHash)}"]`);
if (!target) return false;
target.scrollIntoView({ block: 'start' });
return true;
}
/**
* 渲染 markdown 字符串到视图。
* @param {{path:string,name:string,size?:number,mtimeMs?:number}} file
* @param {string} content - markdown 内容
*/
async loadContent(file, content) {
if (!file || !this.bodyElement) return;
// 恢复空态为 index.html 里的默认文案(showUnsupportedFile 可能改写过了)
this._resetEmptyState();
// 保存当前文件的滚动位置(LRU:删除再 set 移到末尾)
if (this.currentFile && this.bodyElement.parentElement) {
const path = this.currentFile.path;
const top = this.bodyElement.parentElement.scrollTop;
if (this.scrollPositions.has(path)) this.scrollPositions.delete(path);
this.scrollPositions.set(path, top);
// 超出上限:删最早插入的(Map 头部)
while (this.scrollPositions.size > this.SCROLL_POSITION_CACHE_LIMIT) {
const oldest = this.scrollPositions.keys().next().value;
this.scrollPositions.delete(oldest);
}
}
this.currentFile = file;
this.currentContent = typeof content === 'string' ? content : '';
// Phase N M-1 fix:空文件走「(空文件)」占位文案,而不是渲染空 body。
// 旧实现 renderMarkdown('') 返回 '' → bodyElement.innerHTML='' → 用户看到
// 一片空白面板,没有任何提示文件是「真的空」还是「渲染挂了」。
// 与「未打开文件」空态(emptyElement 显示)区分:当前打开了文件,emptyElement 必须
// 继续隐藏,否则用户以为文件没打开。
if (!this.currentContent || this.currentContent.trim() === '') {
this.bodyElement.innerHTML = '<div class="md-empty-file" role="note">(空文件)</div>';
this.bodyElement.hidden = false;
if (this.emptyElement) this.emptyElement.hidden = true;
this._rebuildHeadingObserver();
this.updateStatusBar(file);
return;
}
const html = renderMarkdown(content);
// 自增 generation token,让旧文件的 in-flight resolveRelativeImages 在
// await getDataDir 之后读 _resolveGen 时失配,自行放弃(A→B 切文件场景)。
this._resolveGen += 1;
this.bodyElement.innerHTML = html;
// resolveRelativeImages 内部 await getDataDir() —— loadContent 是 async 不阻塞调用方
// (viewer 不暴露 promise;调用方 fire-and-forget 即可,与原同步行为对齐)
this.resolveRelativeImages(file.path).catch((e) => {
// 数据目录暂时取不到时(preload 还未注入等极端情况)→ 跳过图片解析,
// 但不阻断主渲染流程。日志走 console.warn 让用户能看到。
console.warn('[viewer] resolveRelativeImages 失败:', e && e.message);
});
this.bodyElement.hidden = false;
if (this.emptyElement) this.emptyElement.hidden = true;
// 重绑 heading observer(_rebuildHeadingObserver 内部重建 _headingMap)
this._rebuildHeadingObserver();
// 更新状态栏
this.updateStatusBar(file);
// 恢复滚动位置
// audit fix (L36):捕获 file.path 用于 rAF 内部的「当前文件没变」校验。
// 之前直接用 this.currentFile.path,rAF 回调里 this 已经是新文件(A→B 切得很快
// 时 this.currentFile 已被 B 替换),但 callback 里读 this.scrollPositions.get('B')
// 等于「给 B 应用 A 的滚动位置」。A 的 scrollTop 被顺手存进了 scrollPositions.set('B', A的top)
// —— 第二次开 B 会继承错误的滚动。
// 现在 rAF 内先比对 capturedPath === this.currentFile.path,不一致就放弃 restore。
const capturedPath = file.path;
requestAnimationFrame(() => {
if (!this.currentFile || this.currentFile.path !== capturedPath) return;
// 命中:删除再 set,等价于把这条移到 Map 末尾(最近访问),LRU 语义
if (this.scrollPositions.has(capturedPath)) {
const v = this.scrollPositions.get(capturedPath);
this.scrollPositions.delete(capturedPath);
this.scrollPositions.set(capturedPath, v);
}
const restore = this.scrollPositions.get(capturedPath) || 0;
if (this.bodyElement.parentElement) {
this.bodyElement.parentElement.scrollTop = restore;
}
});
}
/**
* 显示「该文件不支持预览」的空态。
*
* Stage 8 扩展:可编辑文件不一定是 Markdown(txt/py/json/...)—— 这些文件
* 仍然能加载到编辑器,但 viewer 不渲染。空态改成「仅编辑模式」提示,
* 状态栏仍展示文件名 / 大小 / mtime(与 loadContent 行为一致)。
*
* 不修改持久化的 state.editorMode —— 调用方负责把编辑器切到 edit 模式。
* 后续 loadContent / clear 会自动还原空态默认文案。
*
* @param {{path:string,name:string,size?:number,mtimeMs?:number}} file
*/
showUnsupportedFile(file) {
if (!file) return;
this.currentFile = file;
this.currentContent = '';
this.currentHeadingId = null;
this._headingMap = new Map();
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
// 隐藏正文,显示空态并改写文案
if (this.bodyElement) {
this.bodyElement.innerHTML = '';
this.bodyElement.hidden = true;
}
if (this.emptyElement) {
// 直接 innerHTML 改写,保留外层 .empty-state 的布局/动画样式
this.emptyElement.innerHTML = `
<div class="empty-state-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
</div>
<p>该文件不支持预览</p>
<p class="hint">仅 <code>.md</code> 与 <code>.markdown</code> 文件支持 Markdown 预览。<br>其他文本格式(<code>.txt</code> / <code>.py</code> / <code>.json</code> 等)仅支持编辑。</p>
`;
this.emptyElement.hidden = false;
}
// 状态栏仍展示文件名 / 大小 / mtime(与 markdown 文件一致)
this.updateStatusBar(file);
// heading 清空:通知订阅者
this._emitHeadingChange();
}
/**
* 恢复空态元素到 index.html 里的默认文案。
* 由 loadContent / clear 在替换显示内容前调用,避免上次 showUnsupportedFile
* 改写过的「不支持预览」文字残留。
*/
_resetEmptyState() {
if (this.emptyElement && this._defaultEmptyHTML !== undefined) {
this.emptyElement.innerHTML = this._defaultEmptyHTML;
}
}
/**
* 把 <img> 的相对 src 改写成 file:// 绝对地址。
*
* 不改写的话相对路径会相对 index.html(应用安装目录)解析,
* 笔记旁边的图片永远加载不出来。
*
* 只处理相对路径:绝对路径、data:、http(s): 等一律不动。
*
* audit C3 安全修复:解析后的绝对路径必须仍在数据目录内 —— 否则恶意 markdown
* 可以写 `<img src="../../../Users/victim/secret.jpg">` 直接通过 file:// 读到
* 用户机器上的任意可读文件。getDataDir 回调由构造时注入(app.js 传
* `() => window.api.getDataDir()`),取不到时退化为「只挡 ../.. 越界字符串」的旧行为。
*
* @param {string} filePath - 当前 markdown 文件的绝对路径
*/
async resolveRelativeImages(filePath) {
if (!this.bodyElement || !filePath) return;
// audit fix (perf-MEDIUM):A→B 快速切文件时,旧 loadContent 启动的
// resolveRelativeImages(A) 是 fire-and-forget;await getDataDir 期间
// currentFile 已被新 loadContent 替换为本文件 B,但 this.bodyElement
// 也指向已被替换的 DOM(B 的内容)。若不挡,A 的解析结果会污染 B 的图片。
//
// audit fix (renderer-M3):bodyElement 身份永远不会变(同一个 DOM 节点反复
// innerHTML),用 generation token 保证:每次调用入口自增,**关键 await 之后**
// 比对 —— 不一致就 return。token 由 loadContent / loadContentLive 在替换
// innerHTML 之前自增,迫使所有早于本次的内层 await 回调放弃。
//
// B-1 fix (audit):之前在 ++myGen 之后立刻 `if (myGen !== this._resolveGen) return;`
// —— 这是 no-op:自增后立即比对,token 一定相等,要等到 await 期间才可能变化。
// 现在只保留 await 后的真正检查(line ~301)。
const myGen = ++this._resolveGen;
const baseDir = dirnameOf(filePath);
if (!baseDir) return;
// 异步取当前数据目录 —— 失败/缺失时 dataDir 为 null,跳过边界检查(旧行为)。
let dataDir = null;
if (this.getDataDir) {
try {
const got = this.getDataDir();
dataDir = (got && typeof got.then === 'function') ? await got : got;
if (typeof dataDir !== 'string' || !dataDir) dataDir = null;
} catch {
dataDir = null;
}
}
// await 之后再判一次 —— 期间可能被新 loadContent / loadContentLive 接管
if (myGen !== this._resolveGen) return;
for (const img of this.bodyElement.querySelectorAll('img[src]')) {
const src = img.getAttribute('src');
if (!src) continue;
// 带协议(含 data:/file:/http:)或以 / 开头的绝对路径 → 原样保留
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('/') || src.startsWith('//')) continue;
let decoded = src;
try {
decoded = decodeURIComponent(src);
} catch {
// 非法编码就按原文当路径
}
const abs = resolvePath(baseDir, decoded);
if (!abs) continue; // '../..' 越界
// 安全边界:解析后的路径必须在数据目录内。越界直接剥 src,不暴露外部文件路径。
if (dataDir && !isPathInside(abs, dataDir)) {
img.removeAttribute('src');
// 保留视觉占位:把 alt 提到 title,并加一个占位属性让 CSS 可选择
img.setAttribute('data-blocked', 'external');
const alt = img.getAttribute('alt');
if (alt) img.setAttribute('title', `外部图片已拦截:${alt}`);
continue;
}
img.setAttribute('src', toFileUrl(abs));
// audit fix (P3):图片懒加载 + 异步解码 —— 一篇含 20 张图的笔记,
// 之前 marked 直接输出 <img src>,20 个图同时启动下载 + 都参与初始布局,
// 长文档里看不见的图也抢带宽;CLS 也因为没有 width/height 一图一抖。
// loading="lazy" 让浏览器在图进入视口前不下载,decoding="async" 让解码
// 不阻塞主线程(避免大图 decode 卡顿)。这两个属性 setAttribute 已设就
// 不会重复设(getAttribute 检查避免每次重渲都重写)。
if (!img.hasAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.hasAttribute('decoding')) img.setAttribute('decoding', 'async');
}
}
/**
* 同步文件元数据(路径 / 名称 / mtime / size),不重渲染。
*
* P1-5:文件被重命名或重载后,state.currentFile.path/name/mtimeMs/size 都更新了,
* 但 viewer.currentFile 还是旧值 —— viewer 的 resolveRelativeImages 走旧 baseDir,
* 笔记里 `<img src="./foo.png">` 全部失效。
*
* 不全量 reloadContent 是因为:内容没变,只是元数据变了,重渲染会丢滚动位置、
* IntersectionObserver 全量重建 —— 完全不必要。
*
* @param {{path?:string,name?:string,size?:number,mtimeMs?:number}} meta
*/
setFileMeta(meta) {
if (!meta || !this.currentFile) return;
if (meta.path && meta.path !== this.currentFile.path) {
// 路径变了(重命名 / dataDir 切换)→ 必须把 <img src="./..."> 重新解析到新 baseDir
this.currentFile = { ...this.currentFile, ...meta };
// audit fix (Round 4 F8):resolveRelativeImages 抛错兜底。setFileMeta 在
// 重命名 / 切目录高频路径上被调用,里面 for 循环可能因 getDataDir 异常或
// setAttribute 触发 CSP 拦截抛错 —— 之前无 .catch,未捕获的 Promise rejection
// 在 Electron 主进程 default handler 那里打 log。与 loadContent line 186-190 对齐。
Promise.resolve(this.resolveRelativeImages(this.currentFile.path))
.catch((e) => console.warn('[viewer] setFileMeta resolveRelativeImages:', e));
} else {
// 路径没变(只是 mtime / size 变化)→ 仅更新元数据即可
this.currentFile = { ...this.currentFile, ...meta };
}
// 状态栏文件名 / mtime 也要刷新
this.updateStatusBar(this.currentFile);
}
/**
* 清空视图,回到空状态。
*
* audit C2 修复:保留 scrollPositions —— 之前 clear() 一刀切全清,
* 导致「切换文件 → clear → 切回原文件」丢失滚动位置,用户体验差。
* 现在只清当前文件的 entry(其它文件保留),等价于「清空当前视图但 LRU 缓存仍在」。
*/
clear() {
// 恢复空态默认文案(showUnsupportedFile 可能改写过了)
this._resetEmptyState();
if (this.bodyElement) {
this.bodyElement.innerHTML = '';
this.bodyElement.hidden = true;
}
if (this.emptyElement) this.emptyElement.hidden = false;
this.currentFile = null;
this.currentContent = '';
this.currentHeadingId = null;
this._headingMap = new Map();
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
// 只删当前文件的滚动位置 —— loadContent 已经在切换前把 top 存进 scrollPositions,
// 再清一次反而覆盖。clear 的语义就是「不再有 currentFile」,所以 entry 留着无害。
if (this.statusPath) this.statusPath.textContent = this._folderLabel();
if (this.statusMeta) this.statusMeta.textContent = '';
this._emitHeadingChange();
}
/**
* 当前浏览目录刷新 —— 没有打开文件时把状态栏路径切到当前文件夹。
* 由 app.js 在 scanDir / loadFileList 成功后调用,路径信息同步到 chip。
* 有文件打开时不影响(文件路径优先级更高,由 updateStatusBar 控制)。
*/
refreshCurrentDir() {
if (!this.statusPath) return;
if (this.currentFile) return; // 文件路径优先,不覆盖
this.statusPath.textContent = this._folderLabel();
}
/** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到 '—'。 */
_folderLabel() {
const dir = this.getCurrentDir ? this.getCurrentDir() : '';
return dir || '—';
}
/**
* 更新状态栏
*/
updateStatusBar(file) {
if (this.statusPath) {
this.statusPath.textContent = file.path || file.name;
}
if (this.statusMeta) {
const parts = [];
if (typeof file.size === 'number') parts.push(formatSize(file.size));
if (typeof file.mtimeMs === 'number') parts.push(formatDate(file.mtimeMs));
this.statusMeta.textContent = parts.join(' · ');
}
}
/**
* 当前阅读视图的字数统计(CJK 友好)
*
* 词数基于渲染后的纯文本(读者看到的文字,不含 markdown 标记),
* 行数基于源文档 —— 这样切换预览/编辑模式时状态栏的「N 行」不会跳变。
* 之前这里数的是渲染后的块级元素个数,同一个文件在预览下显示 3 行、
* 切到编辑就变成 6 行。
*
* @returns {{chars:number, words:number, lines:number}}
*/
getStats() {
// 行数基于源文档(不是渲染后的 textContent),切预览/编辑时不会跳变。
const source = this.currentContent || '';
return getTextStats(source);
}
/**
* 阅读视图的滚动进度(0..1)
* @returns {number}
*/
getScrollProgress() {
if (!this.bodyElement) return 0;
const scroller = this.bodyElement.parentElement;
if (!scroller) return 0;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return 0;
return Math.min(1, Math.max(0, scroller.scrollTop / max));
}
/**
* 把阅读视图滚动到指定比例(0..1)。供双栏反向同步(editor → viewer / viewer → editor)。
* @param {number} progress
*/
setScrollProgress(progress) {
if (!this.bodyElement) return;
const scroller = this.bodyElement.parentElement;
if (!scroller) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
const target = Math.min(1, Math.max(0, progress)) * max;
scroller.scrollTop = target;
}
/**
* 监听阅读视图自身的滚动(用于反向同步到编辑器)。
*
* audit fix (H1-renderer):listener 必须挂在组件自己的 signal 上 —— 之前
* 没传 signal,dispose() 时 listener 仍存在,闭包持住 viewer + 回调对象,
* 单例 dispose 后再开新组件(旧 cb 在 rAF 里跑 → 抛"viewer disposed"
* 类错误)。现在挂 signal,dispose() 一次性 unbind;同时返回 unsubscribe
* 让调用方可以单独取消(多重订阅场景)。
*
* @param {(progress:number) => void} cb
* @returns {() => void} unsubscribe
*/
onScroll(cb) {
if (!this.bodyElement || !this.bodyElement.parentElement) return () => {};
const scroller = this.bodyElement.parentElement;
const handler = () => {
if (typeof cb === 'function') cb(this.getScrollProgress());
};
scroller.addEventListener('scroll', handler, { passive: true, signal: this.signal });
return () => scroller.removeEventListener('scroll', handler);
}
/**
* 轻量刷新(仅替换 body HTML,不动滚动位置 / 不触发 status bar 更新 / 不存历史)。
*
* 用于「编辑实时预览」:用户敲键盘时右侧实时跟手,又不希望每次都重置滚动。
* 与 loadContent 的关键差异:
* - 不保存当前滚动位置(用户自己滚动到哪里就是哪里)
* - requestAnimationFrame 后不恢复滚动(保持原状)
* - 不更新状态栏
*
* @param {string} content - 最新的 markdown 字符串
* @returns {{changed:boolean}} changed=true 表示内容确实变了
*/
loadContentLive(content) {
if (!this.bodyElement) return { changed: false };
const next = typeof content === 'string' ? content : '';
// M-series fix (audit Phase M-H3):即便内容没变(早返回),也要 bump
// generation token,让上一轮 in-flight 的 resolveRelativeImages 在
// await getDataDir 之后读到旧 myGen → 自行放弃。否则 A→B→A 序列里
// 中间那次 loadContentLive 早返回,pending 的 resolveRelativeImages
// 仍会写入 body,覆盖新内容里的图片路径(用 baseDir 复用旧文件)。
// loadContentLive 的 _resolveGen 跳号不致命:后续 loadContent / 下一次
// loadContentLive 仍会 bump 到新值,最终一致性靠单调递增保证。
if (next === this.currentContent) {
this._resolveGen += 1;
return { changed: false };
}
this.currentContent = next;
const html = renderMarkdown(next);
// 自增 generation token(与 loadContent 同源 —— 每次 innerHTML 替换都让
// 上一轮的 resolveRelativeImages 失效),M7 fix:loadContentLive 内层
// resolveRelativeImages 也必须走 generation 比对,单靠 capturedBody 不够
// (bodyElement 身份不变)。
this._resolveGen += 1;
this.bodyElement.innerHTML = html;
this.resolveRelativeImages(this.currentFile ? this.currentFile.path : null);
// P1-1:增量更新 IntersectionObserver 而不是每次 innerHTML 后全量重建。
// 编辑实时预览里 200ms 防抖触发一次,10K 行的笔记每次重建 observer
// 会做 disconnect + new + N 次 observe,长文档能感知到卡顿。
// 仅当 heading 集合发生变化(增 / 删 / id 变)才重建,否则完全跳过。
// audit fix (Round 4 F11):直呼底层 _rebuildHeadingObserver。_syncHeadingObserver
// 是仅做一层转发的薄壳,签名上的优化语义已被 _rebuildHeadingObserver 全量重建
// 替代,留着壳子只会让读代码的人多绕一层。直接调底层 + 注释说明「全量重建代价
// 可忽略」。
this._rebuildHeadingObserver();
return { changed: true };
}
/**
* 当前在视口里"最顶部可见"的 heading
* @returns {{level:1|2|3, text:string, id:string}|null}
*/
getCurrentHeading() {
if (!this.currentHeadingId || !this._headingMap.size) return null;
return this._headingMap.get(this.currentHeadingId) || null;
}
/**
* 订阅"当前 heading 变化"事件
*
* 用 IntersectionObserver 跟踪每个 heading 元素是否进入/离开视口顶部
* 一带状区域(rootMargin: '0px 0px -80% 0px' 表示视口顶部 20% 那一条带)。
* 多个 heading 可能在同一带里 → 取"位置最高且仍在带内"的最后一个。
*
* @param {(heading: {level:1|2|3, text:string, id:string}|null) => void} cb
* @returns {() => void} 取消订阅函数
*/
onHeadingChange(cb) {
if (typeof cb !== 'function') return () => {};
this.onHeadingChangeCallbacks.add(cb);
// 立刻用当前 heading 触发一次,让订阅者能立刻同步状态
try { cb(this.getCurrentHeading()); } catch { /* ignore */ }
return () => this.onHeadingChangeCallbacks.delete(cb);
}
/**
* 重建 heading IntersectionObserver
*
* 每次 loadContent / loadContentLive 都会调用 —— 因为 DOM 全换了,
* 旧的 observer 持有的 DOM 节点已经无效,必须重建。
*/
_rebuildHeadingObserver() {
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
if (!this.bodyElement) return;
const headings = this.bodyElement.querySelectorAll('h1, h2, h3');
// 同步构建 heading 快查表(id → {level, text, id}),
// 让 getCurrentHeading 直接从 DOM 读取。
this._headingMap = new Map();
for (const h of headings) {
if (!h.id) continue;
this._headingMap.set(h.id, {
level: Number(h.tagName.charAt(1)), // 'H1' → 1
text: h.textContent || '',
id: h.id,
});
}
// 记录当前观察的 heading id 列表(调试 / 复用参考)
this._observedHeadingIds = headings.length === 0 ? [] : Array.from(headings).map(h => h.id);
if (headings.length === 0) {
this.currentHeadingId = null;
this._emitHeadingChange();
return;
}
// 把"当前可见的 heading 集合"实时维护:进入带 → 加入;离开带 → 移除。
// 最后 emit 的 currentHeadingId 是"位置最低且仍在带内"的(最接近顶部
// 但还没滚走的那个 —— 这就是用户在阅读的章节)。
const visibleIds = new Set();
this._headingObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
const id = e.target.id;
if (!id) continue;
if (e.isIntersecting) visibleIds.add(id);
else visibleIds.delete(id);
}
// 找在 visibleIds 里、且 DOM 顺序中位置最低(最后)的 heading
let next = null;
for (const h of headings) {
if (visibleIds.has(h.id)) next = h.id;
}
// 如果一个都不在带内(滚过头了),保持上一次
if (next === null && visibleIds.size === 0) {
// 不主动改 currentHeadingId —— 让"上次已知"留在状态栏
return;
}
// 滚过头(带内全空,且最后位置在视口下方) → 选中最后一个 heading
if (next === null) {
// 用 viewport 顶部坐标最近的 heading
const scroller = this.bodyElement.parentElement;
if (scroller) {
// offsetTop 相对最近的 positioned 祖先;本布局里 .viewer / .markdown-body
// 都是 position: static,offsetParent 一路爬到 <body> —— 与
// scroller.scrollTop 完全不在同一坐标系,会导致「滚过头时永远选不中」。
// 改用 getBoundingClientRect 拿视口坐标,scroller.scrollTop 在两边都加
// 上 → 相互抵消,等价于比较 h.rect.top <= scroller.rect.top。
const scrollerTop = scroller.getBoundingClientRect().top;
let best = null;
let bestDist = Infinity;
for (const h of headings) {
const hTop = h.getBoundingClientRect().top;
if (hTop <= scrollerTop) {
const d = scrollerTop - hTop;
if (d < bestDist) { bestDist = d; best = h.id; }
}
}
next = best;
}
}
if (next !== this.currentHeadingId) {
this.currentHeadingId = next;
this._emitHeadingChange();
}
}, {
root: this.bodyElement.parentElement || null,
// 顶部 0-20% 的"激活带" —— 进入这个带才算"当前正在阅读"
rootMargin: '0px 0px -80% 0px',
// P2-9:单阈值 0 即可。原来的 [0, 1] 会在「进入带」与「完全在带里」各发一次,
// 状态栏 heading chip 在边界来回闪烁;激活带由 rootMargin 控制已经够准确。
threshold: 0,
});
for (const h of headings) this._headingObserver.observe(h);
}
/**
* 增量更新 heading observer(曾经是 P1-1 的 id-only 优化)
*
* 编辑实时预览(loadContentLive)每次都会 innerHTML 替换全部正文。
*
* audit fix (renderer-M1):旧实现靠「heading id 列表相等就跳过重建」来
* 优化,但 innerHTML 替换之后观察的目标节点本身已经被 detach —— id 列表
* 相等(用户改的是正文而非 heading)时 observer 继续盯着 detach 节点,
* 新 heading 节点没有任何 observer,「当前 heading」追踪直接冻死。
*
* heading 数量是 O(N) 且 N 通常 5-50,全量重建代价可忽略。直接走全量重建,
* 简单且正确。
*
* audit fix (Round 4 F11):原 _syncHeadingObserver 是仅做一层转发的薄壳,
* 已被删除,调用方(loadContentLive)直接调 _rebuildHeadingObserver。
*/
_emitHeadingChange() {
if (!this.onHeadingChangeCallbacks.size) return;
const heading = this.getCurrentHeading();
for (const cb of this.onHeadingChangeCallbacks) {
try { cb(heading); } catch (e) { console.error('[viewer] onHeadingChange callback 异常:', e); }
}
}
}

55
src/modal-stack.js Normal file
View File

@@ -0,0 +1,55 @@
// 模态栈(最小版:单槽冲突拒绝)
//
// 现象:
// settingsDialog 与 confirmDialog 都把 DOM 挂到同一个 #modal-root,
// 用 root.replaceChildren(overlay) 覆盖写入。一个还没关就被另一个顶掉,
// 原来 await 的 Promise 永远不会 resolve —— 整条交互挂死。
//
// 策略(用户已选):
// 已有对话框时拒绝新开(不排队、不顶替),调用方自行处理 toast。
//
// 接口:
// isOpen() → boolean
// register(overlay) → void;当前 overlay 入栈
// unregister(overlay) → void;从栈中移除
//
// 设计上保持简单:不监听 DOM,避免和 dialog 自己的 dispose 顺序打架。
// 调用方负责 register(open) / unregister(close) 的配对。
//
// 注:早期版本还有 tryReject() / openOrReject() —— 返回 {ok, reason} 让调用方
// 决定是否 toast。该模式与「isOpen + 早返回 + 自己 toast」相比无收益(前者要
// 调用方再分支),实际零调用方,已删。
let currentOverlay = null;
export function isOpen() {
return currentOverlay !== null && currentOverlay.isConnected;
}
export function register(overlay) {
// audit fix:拒收非 DOM 元素 / null / undefined / 字符串等。旧版不校验,
// 任何传错都会让 isOpen() 在 currentOverlay.isConnected 处抛 TypeError,
// 把「当前是否有模态」这个 hot-path 探测点炸掉,进而影响 IPC 回复路径。
if (!(overlay instanceof Element)) {
throw new TypeError('modal-stack.register: overlay 必须是 DOM Element');
}
currentOverlay = overlay;
}
export function unregister(overlay) {
// audit fix:idempotent —— 不匹配时也清掉已断连的引用。否则:overlay A 被
// 外部 .remove() 后没调 unregister,再 register(B) → currentOverlay=B。
// 之后某个调试路径 unregister(A)(按旧引用传)会发现不等、什么都不做,
// 但 B 的 unregister(B) 仍能把 currentOverlay 清成 null —— 这里其实
// 没问题。真正问题是:A 没调 unregister 又被外部断连,下次 isOpen() 走
// isConnected 检查返回 false,UI 显示「无模态」但栈里仍持有 dead ref,
// 调试时 dump 出来是误导的。
if (currentOverlay === overlay) {
currentOverlay = null;
return;
}
// 不匹配:若 currentOverlay 已不连到 DOM(外部 .remove()),顺手清掉。
if (currentOverlay && !currentOverlay.isConnected) {
currentOverlay = null;
}
}

328
src/modal.js Normal file
View File

@@ -0,0 +1,328 @@
// 通用 modal 骨架 —— 集中 ARIA / Esc / overlay-click / focus-trap / 单槽冲突
//
// 之前 settings-dialog.js 与 confirm-dialog.js 字符级复制了同样的 modal-overlay
// 骨架 + 一套关闭事件(overlay-click / Esc / × 按钮)+ focus-trap 接线。
// 抽到一处后,aria 属性、focus 行为、生命周期都只有一份代码可改。
//
// 设计目标:
// - 调用方只关心「标题 / 正文 / 按钮」三件事
// - 关闭路径(Esc / 点背景 / × 按钮)由 modal 自身统一处理
// - 调用方可以自定义「关闭时取什么值」(resolve(value))
// - 提供 closeValue / updateBody 等逃生口用于更新对话框内容
//
// 调用方式:
// const modal = mountModal({
// title: '...',
// body: '<p>...</p>', // 直接 HTML 字符串(调用方负责 escape)
// footer: '<button>...</button>',
// onAction: (action, modal) => { ... }, // data-action 的按钮触发
// onBackdropClose: () => 'cancel', // 点背景/Esc/× → 哪个 value
// initialFocus: el,
// });
// // 需要关闭时:modal.close(value)
// // 需要更新正文:modal.setBody(html)
import { escapeHtml } from './utils.js';
import { isOpen as modalIsOpen, register as modalRegister, unregister as modalUnregister } from './modal-stack.js';
import { createFocusTrap } from './focus-trap.js';
import { showToast } from './feedback.js';
/**
* 关闭按钮 SVG(×)—— 单一来源
*/
const CLOSE_BUTTON_SVG = `
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1"/>
</svg>
`;
/**
* 挂一个 modal 到 #modal-root,并返回控制器。
*
* @param {object} options
* @param {string} options.title - 标题文本
* @param {string} [options.body=''] - 正文 HTML(调用方负责 escape;可用 setBody() 后续更新)
* @param {string} [options.footer=''] - 底部 HTML(一般放按钮;按钮带 data-action,触发 onAction)
* @param {(action: string, modal: ModalController) =>
* ('close' | undefined | void | Promise<'close' | undefined | void>)} [options.onAction]
* 任意 data-action 按钮点击时触发。返回 'close' 时 modal 会自动 close(action);
* 返回 undefined / void 时 modal 不关,由调用方决定何时 close。
*
* **同步 / 异步两种形态都支持**(审计修复 Round 11 vol.2):
* - 同步函数返回 'close' → 立即同步路径关闭(零延迟)
* - 同步函数返回 undefined → 不关
* - 异步函数 resolve 'close' → 微任务里关闭
* - 异步函数 reject → toast「操作失败:…」(2.4s) + modal 保持打开
* - 同步函数 throw → 同上(toast + 不关)
*
* 实现细节:modal.js 内部用 `Promise.resolve(result).then(...).catch(...)` 把
* 两种形态统一成一个 Promise 链;同步 throw 由外层 try/catch 接。**onAction
* 只会被调用一次** —— 不要在同步与异步路径里各实现一遍业务逻辑。
* @param {string} [options.onBackdropClose='cancel']
* 点 overlay 背景 / 按 Esc / 点 × 按钮时调 close() 用的 value。
* @param {HTMLElement|string} [options.initialFocus]
* 初始焦点元素(或选择器)。默认聚焦 footer 最后一个按钮,否则首个可聚焦元素。
* @param {string} [options.titleId] - aria-labelledby 指向的元素 id(默认自动生成 modal-title-{counter})
* @param {HTMLElement} [options.fallbackFocus]
* focus-trap 关闭时,若打开前 activeElement 已从 DOM 移除(菜单项被 unmount /
* files:changed 重建列表等),焦点回不到原位的兜底锚点。默认 document.body。
* 一般传「打开 modal 之前最常交互的元素」:file-list 右键菜单 → 焦点会回
* 那个 item;search 输入触发的搜索结果 dialog → 回搜索框。**不传**的话
* focus-trap 内置回 document.body。
* @returns {ModalController|null} null = 没拿到 #modal-root;或已有 modal 占位
*/
export function mountModal({
title,
body = '',
footer = '',
onAction,
onBackdropClose = 'cancel',
initialFocus,
titleId,
fallbackFocus = null,
}) {
const root = document.getElementById('modal-root');
if (!root) {
console.error('[modal] #modal-root 缺失');
return null;
}
// 占位检测:与 modal-stack 单槽策略一致;调用方若需要排队,应自己处理。
if (modalIsOpen()) return null;
const abort = new AbortController();
const id = titleId || `modal-title-${Date.now().toString(36)}`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', id);
overlay.innerHTML = `
<div class="modal" role="document">
<div class="modal-header">
<h2 id="${escapeHtml(id)}" class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" type="button" data-action="__modal_close__" aria-label="关闭">${CLOSE_BUTTON_SVG}</button>
</div>
<div class="modal-body" data-modal-body></div>
<div class="modal-footer" data-modal-footer></div>
</div>
`;
// 正文 / footer 用单独容器而非 innerHTML 整段注入,让调用方可以走安全的文本/HTML 边界。
const bodyEl = overlay.querySelector('[data-modal-body]');
const footerEl = overlay.querySelector('[data-modal-footer]');
if (body) bodyEl.innerHTML = body;
if (footer) footerEl.innerHTML = footer;
root.replaceChildren(overlay);
modalRegister(overlay);
// 审计修复 (Round 11 vol.2):挂 inert 背景 app 根节点。
// modal-overlay 本身在 #modal-root 里(独立容器),它的子树不影响背景 DOM。
// 但 background DOM(#app / #file-list / 编辑器 / 工具栏)默认仍然可交互——
// 屏幕阅读器会朗读背景、键盘 Tab 在某些浏览器上仍能跳出 modal、IME 焦点
// 路径不直观。aria-modal="true" 在 AT 上一致性差,给背景打 inert 是
// W3C 推荐的标准化隔离方式。
//
// 选择 #app 而非 document.body —— body 上 inert 会同时让 modal 自身失活;
// #app 是背景应用的稳定根,生命周期长于单次 modal。
// 多 modal 嵌套:当前是单槽设计,不会同时开两个;嵌套场景在 modal-stack 升级到
// 真栈后单独处理。
const appRoot = document.getElementById('app');
const prevAppInert = appRoot ? appRoot.hasAttribute('inert') : false;
if (appRoot && !prevAppInert) {
appRoot.setAttribute('inert', '');
}
// 关闭逻辑:单一入口,所有路径(overlay-click / Esc / × / onAction 触发)最终都走这里。
// onClose 改成回调数组:调用方多次 setOnClose 不再互相覆盖(之前 settings-dialog
// 注册的「清空 API Key」就被后注册的 resolve 静默丢弃,P1-1 修复失效)。
let closed = false;
const onCloseCallbacks = [];
const close = (value) => {
if (closed) return;
closed = true;
abort.abort();
if (overlay.parentElement) overlay.parentElement.removeChild(overlay);
modalUnregister(overlay);
// 复位背景 inert —— 之前没设置过就别删(防御上游已经手动设 inert 的场景,
// 例如正在跑加载遮罩,modal 不应擦掉它的标记)。
if (appRoot && !prevAppInert && appRoot.hasAttribute('inert')) {
appRoot.removeAttribute('inert');
}
// 按注册顺序全部调用;单个 throw 不应阻断其它清理(例如清空敏感字段必须在 resolve 之前)
for (const cb of onCloseCallbacks) {
try {
cb(value);
} catch (e) {
console.error('[modal] onClose callback threw:', e);
}
}
};
// 点背景(点击 overlay 本身而非 modal) → onBackdropClose
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(onBackdropClose);
}, { signal: abort.signal });
// Esc → onBackdropClose
//
// 审计修复 (Round 11):Esc 必须守 IME 合成期 + stopPropagation。
// 1) isComposing / keyCode=229 守卫:CJK 拼音期按 Esc 是「关闭候选词」而不是「关弹窗」。
// 旧版误关 → 用户的拼音/候选词全废、rename/new-note resolve(null) 静默吞掉。
// 与 focus-trap.js:67 / shortcuts.js:80 / prompt-dialog.js:148 保持一致。
// 2) e.stopPropagation():modal 关掉的同一帧 focus 回到 prevFocus(搜索框),
// event 继续冒泡到 document,shortcuts.js:75 看到 activeElement 是 #search-input
// 且 !modalOpen → 调 onClearSearch() 把用户搜索词一并清掉,与用户意图无关的副作用。
overlay.addEventListener('keydown', (e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
close(onBackdropClose);
}
}, { signal: abort.signal });
// [data-action] 按钮:× 按钮(__modal_close__)+ footer 自定义按钮。
// **不拦截 body 内的 [data-action]** —— 那是 dialog 自己的 in-body action,
// 由 dialog 各自 bind 处理(例如 settings-dialog 的「显示/隐藏 API Key」「清空
// AI 配置」按钮)。之前无差别拦截所有 [data-action],body 内这些按钮会被
// onAction 误判为 modal-level 动作(onAction 默认返回 'close' → 立即关 modal),
// 点「显示」结果 modal 直接关掉,UX 直接坏掉。
// 文档化契约:data-action 是 modal-level(footer/header),in-body 行为用别的属性。
overlay.addEventListener('click', (e) => {
const target = e.target.closest('[data-action]');
if (!target || !overlay.contains(target)) return;
const action = target.dataset.action;
if (action === '__modal_close__') {
close(onBackdropClose);
return;
}
// 只处理 footer 内的 data-action;body 内的交给 dialog 自己的 listener
if (!footerEl.contains(target)) return;
if (!onAction) return;
// audit fix (Round 8 M-3):onAction 抛同步错时 modal 不会关,footer 按钮
// 「死」了用户没法关窗。try/catch 后错误打 console,模态保持打开让用户
// 看到具体错误(dialog 自己负责 catch 业务错误;这里是兜底防御)。
//
// 审计修复 (Round 11):onAction 现在支持同步 / 异步两种形态。
// - 同步 throw → try/catch 接住
// - 同步 return 'close' → 同步路径直接 close(action)
// - 同步 return 其他 → 不关(与旧行为一致)
// - 异步 reject → Promise.resolve().catch() 接,escape 到
// window.onunhandledrejection,modal 永不关、footer 按钮「死」
// - 异步 resolve 'close' → .then(r => r === 'close' && close(action))
// 把同步 + 异步统一成一个 Promise 链,避免双重调用 onAction(否则 rename
// / 清 Key 等会被执行两次)。
let result;
try {
result = onAction(action, controller);
} catch (e) {
console.error('[modal] dialog onAction 抛出异常:', action, e);
try {
const msg = (e && typeof e.message === 'string' && e.message) || '操作失败';
showToast(`操作失败:${msg}`, 'error', 2400);
} catch { /* toast 自身抛错就吞 */ }
return;
}
// 同步 'close' 直接关(保持旧路径零延迟);否则交给 Promise.resolve
// 处理(同步非 promise 值 / Promise 都接得住)。
if (result === 'close') {
close(action);
return;
}
Promise.resolve(result).then((r) => {
if (r === 'close') close(action);
}).catch((e) => {
console.error('[modal] dialog onAction 异步异常:', action, e);
try {
const msg = (e && typeof e.message === 'string' && e.message) || '操作失败';
showToast(`操作失败:${msg}`, 'error', 2400);
} catch { /* ignore */ }
});
}, { signal: abort.signal });
// 焦点陷阱(focus-trap 内部已订阅 abort.signal 自动 dispose,这里不需保留引用)。
// audit fix (Settings P3 / focus-trap fallback lift):fallbackFocus 由 caller
// 传入,prevFocus 被外部重渲染/撕下 DOM 时焦点回这个锚点(见 focus-trap.js
// dispose 路径)。默认 null → focus-trap 内部回 document.body。
createFocusTrap(overlay, {
initialFocus: resolveInitialFocus(initialFocus, footerEl),
signal: abort.signal,
fallback: fallbackFocus,
});
const controller = {
/** overlay 根节点 */
overlay,
/** 内层 .modal */
modal: overlay.querySelector('.modal'),
/** 正文容器(可写入 innerHTML) */
body: bodyEl,
/** footer 容器 */
footer: footerEl,
/** 显式关闭(带 value,可由 onClose 拦截) */
close,
/** 替换正文 HTML */
setBody(html) { bodyEl.innerHTML = html; },
/** 替换 footer HTML */
setFooter(html) {
footerEl.innerHTML = html;
// 重新绑定 footer 按钮的事件已经在 overlay 上委托,无需重绑
},
/** 暴露 AbortController,方便调用方挂自己的 listener 到同生命周期 */
abortSignal: abort.signal,
/**
* 注册关闭回调。每次 close(value) 时按注册顺序全部调用一次。
* 多次注册不会被覆盖 —— 调用方可以分别挂上「清理副作用」「resolve promise」
* 等独立关注点,而不必合并到一个回调里。
* @param {(value: string) => void} cb
*/
setOnClose(cb) { onCloseCallbacks.push(cb); },
};
return controller;
}
/**
* 解析 initialFocus:HTMLElement 直接用;字符串 → querySelector;undefined →
* footer 第一个安全按钮。
*
* audit fix (H2-renderer):之前默认焦点是「最后一个按钮」—— 对 confirm-dialog
* 之类 [取消, 删除] / [取消, 确认] 布局,最右侧往往是 primary/danger,按 Enter
* 就直接执行破坏性操作。改成「第一个 .btn-secondary(安全的取消类),没有再
* 回退到第一个按钮」:用户按 Enter 默认是安全的取消/返回,符合 WAI-ARIA
* "least destructive option" 原则;需要主动执行破坏性动作时再鼠标/Tab 选。
*
* @param {HTMLElement|string|undefined} initialFocus
* @param {HTMLElement} footerEl
* @returns {HTMLElement|null}
*/
function resolveInitialFocus(initialFocus, footerEl) {
if (initialFocus instanceof HTMLElement) return initialFocus;
if (typeof initialFocus === 'string') {
return document.querySelector(initialFocus);
}
if (footerEl) {
const safe = footerEl.querySelector('.btn.btn-secondary');
if (safe) return safe;
const btns = footerEl.querySelectorAll('.btn');
if (btns.length > 0) return btns[0];
}
return null;
}
/**
* @typedef {object} ModalController
* @property {HTMLElement} overlay
* @property {HTMLElement} modal
* @property {HTMLElement} body
* @property {HTMLElement} footer
* @property {(value: string) => void} close
* @property {(html: string) => void} setBody
* @property {(html: string) => void} setFooter
* @property {AbortSignal} abortSignal
* @property {(cb: (value: string) => void) => void} setOnClose
*/

242
src/prompt-dialog.js Normal file
View File

@@ -0,0 +1,242 @@
// 通用输入对话框 —— Electron 渲染端不支持 window.prompt() / window.alert(),
// 我们基于 mountModal 自己搭一个。
//
// 用法(语义与 window.prompt() 完全一致,便于替换):
// const value = await showPromptDialog({
// title: '重命名',
// message: '重命名为(自动补 .md 后缀)', // 可选;放在输入框上方
// defaultValue: '当前名', // 可选;默认空串
// placeholder: '请输入…', // 可选
// inputLabel: '文件名', // 可选;屏幕阅读器朗读用
// confirmLabel: '确定', // 可选
// cancelLabel: '取消', // 可选
// selectAllOnOpen: true, // 可选;默认 true,模仿 window.prompt()
// validate: (raw) => { // 可选;客户端预校验
// if (!raw.trim()) return '文件名不能为空';
// if (/[/\\]/.test(raw)) return '文件名不能包含路径分隔符';
// return null; // null = 通过
// },
// });
// if (value == null) return; // 用户取消 → null
// const trimmed = value.trim(); // 确认 → 原样字符串(含前后空格,由调用方决定是否 trim)
//
// 设计要点:
// - 完全跑在 mountModal 上,复用 ARIA / focus-trap / Esc / overlay-click / 单槽冲突
// - Enter 提交(焦点在 input 内时),Esc / × / 点背景 = 取消
// - 打开时全选默认文字(模仿 window.prompt() 行为,便于「重命名」场景直接覆盖)
// - mountModal 失败(#modal-root 缺失 / 已有 modal)→ 退化到 window.prompt()
// (与 confirm-dialog.js 的兜底一致;理论上 Electron renderer 里没有 prompt,
// 但保持接口形态一致)
// - 可选 validate:返回 null = 通过;返回字符串 = 在 input 下方显示为错误,
// dialog 保持打开、不关闭。空字符串 = 通过(无错误信息)。
//
// 边界:
// - 只读 / 不写 ctx 之外的全局状态
// - 调用方负责校验空字符串、文件名合法性等
import { escapeHtml, escapeAttr } from './utils.js';
import { mountModal } from './modal.js';
// audit fix (Phase L2 #7):modal 被另一个对话框占位时给用户 toast,避免
// 重要操作(重命名 / 新建文件)静默失败。
import { showToast } from './feedback.js';
const DEFAULT_CONFIRM_LABEL = '确定';
const DEFAULT_CANCEL_LABEL = '取消';
const INPUT_ID = 'prompt-dialog-input';
const ERROR_ID = 'prompt-dialog-error';
/**
* 显示一个输入对话框
* @param {object} options
* @param {string} options.title - 标题(必填)
* @param {string} [options.message] - 正文(可选;放在输入框上方)
* @param {string} [options.defaultValue=''] - 输入框初始值
* @param {string} [options.placeholder] - 输入框 placeholder
* @param {string} [options.inputLabel] - 输入框 label(屏幕阅读器朗读;视觉上仍可见)
* @param {string} [options.confirmLabel='确定'] - 确认按钮文本
* @param {string} [options.cancelLabel='取消'] - 取消按钮文本
* @param {boolean} [options.selectAllOnOpen=true] - 打开时是否全选默认文字
* @param {(raw: string) => (string|null|undefined)} [options.validate] -
* 客户端预校验;返回 null/undefined = 通过;返回字符串 = 错误消息(dialog 保持打开)
* @returns {Promise<string|null>} 用户输入的字符串;取消时返回 null
*/
export function showPromptDialog({
title,
message = '',
defaultValue = '',
placeholder = '',
inputLabel = '',
confirmLabel = DEFAULT_CONFIRM_LABEL,
cancelLabel = DEFAULT_CANCEL_LABEL,
selectAllOnOpen = true,
validate = null,
} = {}) {
return new Promise((resolve) => {
if (!title) {
// 没有标题 → 与 mountModal 行为一致地退化,避免静默卡住调用方
resolve(null);
return;
}
const messageHtml = message
? `<p class="confirm-message">${escapeHtml(message)}</p>`
: '';
// 审计修复 (Round 11 vol.2):输入框下方预留给 validate 错误信息;
// 初始为空(无错误态),validate 触发时填文案 + aria-invalid。
const bodyHtml = `
${messageHtml}
<div class="form-row">
<label class="form-label" for="${INPUT_ID}">${escapeHtml(inputLabel || title)}</label>
<input
id="${INPUT_ID}"
class="form-input"
type="text"
${placeholder ? `placeholder="${escapeAttr(placeholder)}"` : ''}
value="${escapeAttr(defaultValue)}"
maxlength="255"
autocomplete="off"
spellcheck="false"
aria-describedby="${ERROR_ID}"
>
<p id="${ERROR_ID}" class="form-error" role="alert" hidden></p>
</div>
`;
const footerHtml = `
<button class="btn btn-secondary" type="button" data-action="cancel">${escapeHtml(cancelLabel)}</button>
<button class="btn btn-primary" type="button" data-action="confirm">${escapeHtml(confirmLabel)}</button>
`;
// tryConfirm 必须先声明(即便 body 还没渲染)—— 因为 mountModal 的 onAction
// 回调是延迟触发的,user 真正点按钮 / 按 Enter 时 inputEl / errorEl 都已就绪。
// 这里只放一个 forward declaration;函数体和引用在 inputEl 之后才生效。
// audit fix (Phase L1-Settings P1):Enter 与按钮 click 必须走完全同一条
// validate 路径。早期版本 Enter 直接 modal.close('confirm') 跳过校验,
// 导致重命名场景输入非法名 → dialog 静默关闭 → IPC 拒、用户输入丢失。
let tryConfirm = () => undefined;
const modal = mountModal({
title,
body: bodyHtml,
footer: footerHtml,
// 初始焦点放到输入框(focus-trap 在 queueMicrotask 里调 focus)
initialFocus: `#${INPUT_ID}`,
// Esc / × / 点背景 = 取消(与浏览器原生 prompt 一致)
onBackdropClose: 'cancel',
onAction: (action) => {
// cancel 直接让 mountModal 关闭;confirm 委托给 tryConfirm,
// 由它决定「validate 失败保持打开」或「成功 close」。
if (action === 'cancel') return 'close';
if (action === 'confirm') {
// 返回 undefined(mountModal 不再负责关闭),因为 tryConfirm 内部
// 已经按需调用 modal.close('confirm')。
tryConfirm();
return undefined;
}
return undefined;
},
});
if (!modal) {
// mountModal 失败(#modal-root 缺失 / 已有 modal 占位)
// 注意:不能 fallback 到 window.prompt() —— Electron 渲染端 prompt() 会抛错
// ('prompt() is and will not be supported'),正好是我们要绕开的坑。
// 兜底行为:当作取消处理(resolve null),并 toast 提示用户,让重要
// 操作(重命名 / 新建文件)不会静默无反应(audit L2 #7)。
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
resolve(null);
return;
}
const inputEl = modal.body.querySelector(`#${INPUT_ID}`);
const errorEl = modal.body.querySelector(`#${ERROR_ID}`);
if (!inputEl) {
// 极端兜底:input 没渲染出来 —— 直接关掉并 resolve(null),不挂死调用方
modal.close('cancel');
resolve(null);
return;
}
// 错误显示/清除:单点维护行内错误 UI 行为(aria-invalid + 文本 + hidden)。
function showError(msg) {
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.hidden = false;
inputEl.setAttribute('aria-invalid', 'true');
inputEl.classList.add('is-invalid');
}
function clearError() {
if (!errorEl) return;
errorEl.textContent = '';
errorEl.hidden = true;
inputEl.removeAttribute('aria-invalid');
inputEl.classList.remove('is-invalid');
}
// 用户每次输入都清错误 —— 让他们看到「错误已识别,现在改正」反馈。
// 守卫:在没 validate 的场景下也保留(clearError 是 no-op 安全)。
inputEl.addEventListener('input', () => {
if (inputEl.getAttribute('aria-invalid') === 'true') clearError();
}, { signal: modal.abortSignal });
// 单一 resolve 出口:所有关闭路径(Cancel / × / Esc / 背景 / Confirm / Enter)都走这里
modal.setOnClose((value) => {
// Promise resolve 是幂等的,重复调用安全
if (value === 'cancel') resolve(null);
else resolve(inputEl.value);
});
// IME composition 跟踪 —— CJK 输入法按 Enter 是「确认候选词」而非「提交表单」。
// 不跟踪会让正在打拼音的用户按 Enter 直接关闭弹窗,丢掉半截输入。
// 参考 src/ai/ai-chat-panel.js 的 _composing 模式(更严谨的 compositionstart/end)。
let composing = false;
inputEl.addEventListener('compositionstart', () => { composing = true; }, { signal: modal.abortSignal });
inputEl.addEventListener('compositionend', () => { composing = false; }, { signal: modal.abortSignal });
inputEl.addEventListener('blur', () => { composing = false; }, { signal: modal.abortSignal });
// 单一确认入口:按钮 click 与 Enter 键都路由到这里。
// audit fix (Phase L1-Settings P1):早期 Enter 直接 modal.close('confirm'),
// 完全绕过 validate 校验。重命名场景输入非法名(路径分隔符 / 保留设备名)
// 按 Enter → dialog 静默关闭 → IPC 拒、用户 typed text 丢失、错误只通过
// 通用 toast 模糊暴露。现在 validate 失败时显示行内红字并保持打开,
// 与按钮 click 走完全同一条路径。
tryConfirm = () => {
// 客户端预校验(Round 11 vol.2):返回 null/undefined = 通过;
// 返回字符串 = 错误消息(dialog 保持打开)。
if (typeof validate === 'function') {
let errMsg = null;
try {
errMsg = validate(inputEl.value);
} catch (e) {
// validate 自身抛错 → 当成错误消息展示(不静默吞)
errMsg = (e && typeof e.message === 'string' && e.message) || '输入校验失败';
}
if (errMsg) {
showError(errMsg);
// input 焦点留在原位(用户改完再确认)
try { inputEl.focus({ preventScroll: true }); } catch { /* ignore */ }
return;
}
clearError();
}
modal.close('confirm');
};
// Enter 提交(焦点在 input 内时);与 form submit 等价但更直接。
// 守卫 e.isComposing + composing flag:拼音 / 日语 IME 用 Enter 确认候选词时不应触发提交。
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing && !composing) {
e.preventDefault();
tryConfirm();
}
}, { signal: modal.abortSignal });
// 打开时全选默认文字(模仿 window.prompt() 行为)。
// focus-trap 内部已用 queueMicrotask 调 focus,我们在它之后排一个 microtask 即可。
if (selectAllOnOpen && defaultValue) {
queueMicrotask(() => {
try { inputEl.select(); } catch { /* input 已不在 DOM */ }
});
}
});
}

46
src/save-button-ui.js Normal file
View File

@@ -0,0 +1,46 @@
// 保存按钮 UI 同步 —— 纯函数,与 src/app.js 解耦,便于单测。
//
// 设计:
// - 始终显示按钮(见 [[feedback-save-button-always-visible]]),靠 class / aria
// 表达三态:保存中 / 有待保存改动 / 干净。
// - 「保存中」用 savingCount 计数器(>0 即为保存中)—— 共享 inFlightSave 期间
// 多次进入 save() 不会因共享 then 路径无 finally 而闪烁;归零才真正熄灭。
// - 不设 disabled 属性 —— Ctrl+S 走 EditorView keymap(与 DOM disabled 无关),
// 设了反而与 click / hover 反馈不一致。视觉提示用 cursor + opacity。
/**
* 把 state.isDirty / state.savingCount 同步到按钮的 class / aria-busy / aria-label / title,
* 并把 isDirty 同步到状态栏「未保存」chip(hidden + 文案)。
* 同时把 isDirty 通过 setDirtyFn 回调传出(用于 IPC 通知主进程)。
*
* @param {Object} args
* @param {HTMLElement|null} args.btnSave 保存按钮 DOM(可为 null,函数静默 return)
* @param {HTMLElement|null} [args.statusUnsaved] 状态栏未保存 chip(可选;缺省不更新)
* @param {Object} args.state renderer state(用 isDirty / savingCount)
* @param {(dirty: boolean) => any} [args.setDirty] 异步通知外部(IPC);缺省空操作
*/
export function syncSaveButton({ btnSave, statusUnsaved, state, setDirty = () => {} }) {
if (!btnSave) return;
const isSaving = state.savingCount > 0;
btnSave.classList.toggle('is-dirty', state.isDirty);
btnSave.classList.toggle('is-saving', isSaving);
btnSave.setAttribute('aria-busy', isSaving ? 'true' : 'false');
// 三态文案:保存中 > 有待保存改动 > 干净
const label = isSaving
? '保存中…'
: (state.isDirty ? '保存(有待保存的改动)' : '保存');
btnSave.setAttribute('aria-label', label);
btnSave.title = isSaving
? '保存中…'
: (state.isDirty ? '保存(有待保存的改动) (Ctrl+S)' : '保存 (Ctrl+S)');
// 状态栏「未保存」chip:preview / 全屏等场景下,工具栏按钮可能被遮挡,
// 状态栏右下角的红点 chip 是更显眼的脏态指示。
// 只反映 isDirty —— is-saving 是过渡态,飞行结束会立刻切回脏/干净,
// 不需要重复在状态栏展示(避免一明一灭的视觉抖动)。
if (statusUnsaved) {
statusUnsaved.hidden = !state.isDirty;
statusUnsaved.textContent = '未保存';
statusUnsaved.setAttribute('aria-label', '当前文件有未保存的改动');
}
setDirty(state.isDirty);
}

83
src/save-toast.js Normal file
View File

@@ -0,0 +1,83 @@
// 保存路径 toast 决策(Phase N Q-fix)
//
// 把 save() 主路径与 shared path 都需要的 toast 决策抽成纯函数,方便单测
// 覆盖所有 silent × result 组合,避免两端 inline if/else 漂移。
//
// 历史问题:save() IIFE 内部曾直接 `if (!silent) showToast('已保存')`,
// shared path 复用 IIFE 返回值时用的是 IIFE 创建者的 silent —— 用户连按
// 两次 Ctrl+S(auto-save silent=true 飞行期 user silent=false 手动),silent
// 标志被 IIFE 吞掉,manual caller 收不到「已保存」反馈。重构后:
// - IIFE 内部不再弹 success toast,统一返回 {ok, reason}
// - save() outer(主路径 + shared path)按自己的 silent 决定是否弹
// - shared path 还有 stale 场景(飞行期切文件 → IIFE 静默 return STALE),
// silent=false manual caller 必须弹「文件已切换,未保存到磁盘」
//
// 错误 toast(FILE_CHANGED_EXTERNALLY 各分支 / FILE_NOT_FOUND / PATH_NOT_ALLOWED
// / SYMLINK_NOT_ALLOWED / errno 兜底 / IPC_ERROR)仍在 IIFE 各分支里按 result.error
// 弹,与 silent 无关 —— 所有 silent 都弹错误(用户必须感知失败)。本 helper
// 只管 success + STALE 两种 toast 决策。
//
// @param {boolean} silent
// @param {{ok:boolean, reason?:string}|null|undefined} result
// @param {(message: string, type: 'success'|'warning'|'error'|'info', duration: number) => void} showToast
// @returns {void}
/**
* 根据调用方 silent 标志 + IIFE 返回结果决定是否弹 toast。
*
* 决策矩阵:
* - silent=false + ok=true(无 STALE / NO_CHANGES 标记) → 「已保存」(success, 2500ms)
* - silent=false + reason='STALE'(无论 ok) → 「文件已切换,未保存到磁盘」(warning)
* Phase 2 audit 修复 (P1 #3):STALE 不再等同「写盘失败」。飞行期用户切
* 走时 writeFile 已 resolve,data 可能已落 targetPath。ok=true 让关窗路径
* 能正确退出;用户视角的反馈(manual Ctrl+S silent=false)应该总是 STALE
* 提示(因为 editor 已不在 targetPath 上),文案保持中性「文件已切换,未
* 保存到磁盘」—— 如果用户想知道数据到底有没有存,去看 A 文件本身。
* - silent=false + reason='NO_CHANGES' → 「已是最新」(info, 1500ms)
* fix(audit 2026-08):auto-save 静默写盘后 state.isDirty=false,用户手动
* 点保存按钮落入 !state.isDirty 早退路径,旧逻辑静默 return 让用户觉得
* 「按钮坏了 / 没保存上」—— 实际文件已最新。给一个简短 info 让点击有反馈。
* auto-save silent=true 路径不影响(silent 早退)。
* - silent=false + reason='NO_FILE' → 「没有打开文件」(info, 1500ms)
* fix(audit Round 7 S-3):旧版与 NO_CHANGES 共用「已是最新」文案,但语义
* 错位 ——「已是最新」暗示「有文件且未改动」,用户视角「什么都没打开,
* 按 Ctrl+S 当然没东西存」需要准确的「没有打开文件」。auto-save silent
* 路径不影响。
* - silent=true → 啥都不弹(auto-save / 关窗 silent=true 静默场景)
* - 其它失败 reason(CONFLICT_* / FILE_NOT_FOUND / PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED
* / WRITE_FAILED / IPC_ERROR)→ 错误 toast 已在 IIFE 各分支按 result.error 弹过,
* 本 helper 不重复弹(避免误导性「保存失败(请重试)」覆盖 IIFE 已弹的具体原因)。
*
* 「已保存」duration 从 1200ms 提到 2500ms(fix audit 2026-08):1200ms 太快,
* 用户实际只看到约半秒淡出动画,根本来不及形成「保存完成」的认知,导致
* 「保存按钮失灵」类错觉反复出现。2500ms 是大多数 IDE(VS Code / Sublime)
* 「Saved」toast 的常用区间,足够看见。
*/
export function decideSaveToast(silent, result, showToast) {
if (silent) return;
if (!result) return;
if (result.reason === 'STALE') {
showToast('文件已切换,未保存到磁盘', 'warning', 2500);
return;
}
if (result.reason === 'NO_CHANGES') {
showToast('已是最新', 'info', 1500);
return;
}
if (result.reason === 'NO_FILE') {
// audit fix (Round 7 S-3):与 NO_CHANGES 区分。语义是「没有打开文件」
// 而不是「文件已是最新」,文案准确反映状态。
showToast('没有打开文件', 'info', 1500);
return;
}
if (result.reason === 'DELEGATED') {
// audit fix (Round 8 S-1):force_overwrite 把写盘委托给递归 inner save(),
// inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责
// 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。
return;
}
if (result.ok) {
showToast('已保存', 'success', 2500);
}
// 其它失败 reason:IIFE 内部已按 result.error 弹过原因 toast,这里不重复。
}

1002
src/settings-dialog.js Normal file

File diff suppressed because it is too large Load Diff

225
src/settings-store.js Normal file
View File

@@ -0,0 +1,225 @@
// 设置存储
// 封装主进程 IPC 的 getSettings / saveSettings,提供本地缓存和 change 事件订阅。
//
// 持久化由主进程负责(userData/config.json),这里只做内存缓存 + 同步广播。
//
// 关键能力(Stage 7 增强):
// - preUpdateHook:写主进程前可拦截;返回 false 取消(用于「未保存改动时改 dataDir」之类场景)
// - 单调 token:并发的 update 调用,后到的响应不会覆盖后到的状态(audit #9)
import { EventEmitter } from './event-emitter.js';
// schema 经 preload 的 contextBridge 过桥,不能直接 import
// shared/settings-schema.js —— 那是 CommonJS,而 renderer 是 Chromium
// 原生 ESM(nodeIntegration:false),没有 CJS 互操作,import 会让整个应用起不来。
// preload 先于 renderer 模块求值,所以这里在顶层取值是安全的。
const DEFAULT_SETTINGS = (typeof window !== 'undefined'
&& window.api?.settingsSchema?.DEFAULT_SETTINGS) || {};
/**
* 归一化设置对象。桥不可用时退化为「默认值 + 已知键覆盖」。
* @param {*} raw
*/
function coerceLoadedSettings(raw) {
if (typeof window !== 'undefined' && typeof window.api?.coerceLoadedSettings === 'function') {
return window.api.coerceLoadedSettings(raw);
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...DEFAULT_SETTINGS };
const out = { ...DEFAULT_SETTINGS };
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (key in raw) out[key] = raw[key];
}
return out;
}
export class SettingsStore extends EventEmitter {
/**
* @param {object} api - window.api(由 preload 注入的 contextBridge API)
*/
constructor(api) {
super();
this.api = api;
this._settings = { ...DEFAULT_SETTINGS };
this._loaded = false;
// preUpdateHook: (partial, currentSettings) => boolean | Promise<boolean>
// 返回 false 取消 update(不会写入内存,也不会触发 IPC)。
// 用于 app.js 在 dataDir 变更时先检查编辑器是否有未保存改动(audit #1)。
this._preUpdateHook = null;
// 单调递增 token:每次 update 自增;响应回来时只接受 token 匹配的写入。
// 防止「A 提交 → B 提交 → B 响应先到 → A 响应后到覆盖 B 的状态」(audit #9)。
this._updateToken = 0;
}
/**
* 注册 preUpdateHook。同一时刻只允许一个 hook;后注册的覆盖前者。
* hook 抛错视作「拒绝更新」。
* @param {(partial: object, current: object) => boolean | Promise<boolean>} hook
*/
setPreUpdateHook(hook) {
this._preUpdateHook = typeof hook === 'function' ? hook : null;
}
/**
* 取当前注册的 preUpdateHook。返回函数或 null。
* 供需要在临时覆盖前先备份、用完再恢复的场景(例如 app.js 的 settings 对话框
* 临时挂一个 dataDir 脏检查的 hook,关闭后还原)。
*/
getPreUpdateHook() {
return this._preUpdateHook;
}
/**
* 从主进程拉取设置到内存
*/
async load() {
if (!this.api || typeof this.api.getSettings !== 'function') {
console.warn('[settings-store] window.api.getSettings 不可用,使用默认值');
this._settings = { ...DEFAULT_SETTINGS };
this._loaded = true;
this.emit('change', { settings: this._settings });
return this._settings;
}
try {
const remote = await this.api.getSettings();
this._settings = coerceLoadedSettings(remote);
} catch (e) {
console.error('[settings-store] load 失败:', e);
this._settings = { ...DEFAULT_SETTINGS };
}
this._loaded = true;
this.emit('change', { settings: this._settings });
return this._settings;
}
/**
* 读取单个设置
*/
get(key) {
return this._settings[key];
}
/**
* 读取所有设置
*/
getAll() {
return { ...this._settings };
}
/**
* 部分更新设置(异步写入主进程)
*
* 策略:乐观更新 —— 先在本地应用,立刻 emit 'change' 通知监听者;
* 然后异步写主进程。**失败时回滚到写入前的值并再 emit 一次 'change'**,
* 否则界面会停留在一个磁盘上并不存在的状态(比如显示着一个校验失败的 dataDir)。
*
* 并发安全:每次 update 取一个单调递增 token;响应回来时若 token 已被新调用顶掉,
* 直接丢弃响应(不在本地应用),避免后到的旧响应覆盖新状态(audit #9)。
*
* @param {object} partial
* @returns {Promise<object>} 持久化后的完整设置
* @throws {Error} 主进程校验失败(例如 dataDir 不存在)、IPC 异常、或 preUpdateHook 拒绝
*/
async update(partial) {
if (!partial || typeof partial !== 'object') return this._settings;
// preUpdateHook 拦截(audit #1)
if (this._preUpdateHook) {
let proceed = true;
try {
proceed = await this._preUpdateHook(partial, this._settings);
} catch (e) {
console.error('[settings-store] preUpdateHook 抛错:', e);
proceed = false;
}
if (!proceed) {
throw new Error('SETTINGS_UPDATE_CANCELLED');
}
}
// 取 token;并发的 update 会拿到不同 token,旧的 IPC 响应会被丢弃
const myToken = ++this._updateToken;
// 立刻在本地应用(乐观更新)。
// L3 fix (audit):屏蔽 aiApiKey —— 真实值仍通过 api.saveSettings 发给主进程,
// 但 _settings 缓存不存真值。主进程 echo 的 settings 也是脱敏后的(aiApiKey=''),
// 写盘后 getAll() 也只返回 ''。这样乐观窗口期内(IPC 飞行中)任何 read path
// (getAll / emit 的 settings)都拿不到真值。
// L9 fix (audit):emit 的 `changes` payload 也 redact aiApiKey —— 之前
// partial.aiApiKey 是明文发出去的,虽然当前唯一监听器 syncBehaviorPrefsUI
// 只读 theme/font 等无关字段,但防御性 redact 让「将来新增监听器不会
// 因为忘了过滤而拿到真值」。'***' 而不是 '',保留「字段被改过」的语义
// (partial.aiApiKey === '' 不一定代表用户清空 Key,也可能是 reveal 后又改)。
const before = { ...this._settings };
const cachedPartial = { ...partial };
const emitChanges = { ...partial };
if ('aiApiKey' in cachedPartial) {
cachedPartial.aiApiKey = ''; // 不缓存真值
emitChanges.aiApiKey = '***'; // 通知监听器「Key 字段有变」但不暴露值
}
this._settings = { ...this._settings, ...cachedPartial };
this.emit('change', { settings: this._settings, changes: emitChanges, previous: before });
if (!this.api || typeof this.api.saveSettings !== 'function') {
return this._settings;
}
const rollback = (reason) => {
// 只有当我们仍是「最新调用」时才回滚;否则让新调用的状态保留
if (myToken !== this._updateToken) return;
const failed = { ...this._settings };
this._settings = before;
// audit fix (H2-settings):原 emit 用 `changes: before`,那是「写之前」的
// 旧值快照,下游若按正常语义解读会把旧值当成新变更去应用。回滚的语义
// 应当是「刚才那批 partial 提交失败、状态已退回」—— changes 给 partial
// (试图落盘但失败的内容),previous 给 failed(被撤回的中间态),并附
// rolledBack=true + reason 让监听器按异常路径分支处理。
//
// Phase N 修复:redact aiApiKey —— partial 里可能含用户刚输入的真值 Key。
// emitChanges 已用 '***' 占位(L9 fix),这里同样把 partial 的 aiApiKey 抹掉,
// 防止监听器从 changes payload 里拿回真值。
const emitChanges = { ...partial };
if ('aiApiKey' in emitChanges) emitChanges.aiApiKey = '***';
this.emit('change', {
settings: this._settings,
changes: emitChanges,
previous: failed,
rolledBack: true,
reason,
});
};
let res;
try {
res = await this.api.saveSettings(partial);
} catch (e) {
console.error('[settings-store] update IPC 异常:', e);
rollback(e?.message);
throw e;
}
// 响应已过时:被新调用顶掉了;直接丢弃(不应用也不回滚,新调用有自己的 before)
if (myToken !== this._updateToken) {
return this._settings;
}
// 主进程返回 { ok:false, error } 表示校验失败(如目录不存在)
if (res && res.ok === false) {
const message = res.error || '保存设置失败';
rollback(message);
throw new Error(message);
}
// 当前协议:{ ok:true, settings }
if (res && res.settings && typeof res.settings === 'object') {
this._settings = coerceLoadedSettings(res.settings);
}
return this._settings;
}
/**
* 是否已从主进程加载
*/
isLoaded() {
return this._loaded;
}
}

206
src/shortcuts.js Normal file
View File

@@ -0,0 +1,206 @@
// 全局快捷键
//
// 10 个常用快捷键(详见下文清单):
// - Ctrl/Cmd + F 聚焦文件搜索框(编辑器内让给 CM6 查找面板)
// - Ctrl/Cmd + S 保存当前编辑的文件(脏状态时)
// - Ctrl/Cmd + E 切换视图模式(preview → edit → split)
// - Ctrl/Cmd + N 新建文件
// - Ctrl/Cmd + , 打开设置
// - Ctrl/Cmd + R 重新加载界面(与主菜单 reload 等价)
// - Ctrl/Cmd + Shift+T 切换主题
// - Ctrl/Cmd + Shift+F 切换聚焦模式(隐藏工具栏/侧栏/状态栏)
// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6)
// - Esc(搜索框内) 清空搜索
//
// 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时),
// 但事件仍会继续冒泡到 document —— 所以这里必须主动避让,否则
// 在编辑器里按 Ctrl+F 会「同时」打开 CM 搜索面板并把焦点抢到侧栏搜索框。
//
// 模态对话框打开期间(#modal-root 有 overlay),Ctrl+, / Ctrl+F / Ctrl+Shift+T
// 应当静默不触发,否则会把焦点抢走、或者顶掉现有 dialog(详见 modal-stack.js)。
// 模态自己的 Esc → 关闭 在 dialog 内部已经独立处理。
// Ctrl+R 不抑制:模态挂死时这反而是逃生口。
import { isOpen as modalIsOpen } from './modal-stack.js';
/**
* 事件是否发生在 CodeMirror 编辑器内部。
* @param {Event} e
* @returns {boolean}
*/
function isInsideEditor(e) {
const target = e.target;
if (!target || typeof target.closest !== 'function') return false;
return !!target.closest('.cm-editor');
}
/**
* @param {object} handlers
* @param {() => void} handlers.onFocusSearch
* @param {() => void} handlers.onClearSearch
* @param {() => void} handlers.onSettings
* @param {() => void} handlers.onToggleTheme
* @param {() => void} handlers.onSave - Ctrl/Cmd+S
* @param {() => void} handlers.onToggleEditorMode - Ctrl/Cmd+E
* @param {() => void} [handlers.onNewFile] - Ctrl/Cmd+N
* @param {() => void} [handlers.onToggleFocusMode] - Ctrl/Cmd+Shift+F
* @param {() => void} [handlers.onToggleAi] - Ctrl/Cmd+Shift+A(AI 修改面板)
* @param {() => void} [handlers.onReload] - Ctrl/Cmd+R(README 之前提到但未注册——audit 补全)
*/
export function mountKeyboardShortcuts({
onFocusSearch,
onClearSearch,
onSettings,
onToggleTheme,
onSave,
onToggleEditorMode,
onNewFile,
onToggleFocusMode,
onToggleAi,
onReload,
}) {
// audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。
// 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount
// 会让 keydown listener 累积(每个按键触发 N 个回调);生产路径虽然只 mount
// 一次不致命,但 devtools 反复 Ctrl+R 重载 + HMR 场景下 listener 长期持有
// handlers 闭包。
const handler = (e) => {
const ctrl = e.ctrlKey || e.metaKey;
const modalOpen = modalIsOpen();
// Esc 清空搜索(仅当焦点在搜索框内,且不在模态内——模态自身监听 Esc)
if (e.key === 'Escape' && document.activeElement?.id === 'search-input' && !modalOpen) {
// audit fix (Round 4 F3):CJK IME 合成期间按 Esc 想收候选窗口时,document
// 仍可能 fire keydown。本分支若不拦,会调用 onClearSearch 清空搜索词 + IME
// 半截输入一起丢。下方 Ctrl+letter 分支已有 isComposing/keyCode=229 守卫,
// Esc 分支同样加上保持对称。
if (e.isComposing || e.keyCode === 229) return;
if (onClearSearch) onClearSearch();
return;
}
if (!ctrl) return;
// C3 fix (audit):CJK IME 合成期间(CJ / 中文拼音 / 日文 IME 等)浏览器
// 仍会 fire keydown 给 Ctrl+letter 组合,且 key 已是真实字符(如 'e')。
// 此时触发「切换视图模式 / 新建文件 / 切换 AI」会让用户
// 拼音还没敲完视图就跳了 —— 而且 editor 是 CM6 容器时,模式切换会
// destroy view,合成中的 IME buffer 跟着丢。
// keyCode === 229 是历史 fallback(Firefox / 旧 Chromium 用 keyCode 标识
// composition 状态),与 isComposing 一起兜底。
if (e.isComposing || e.keyCode === 229) return;
const inEditor = isInsideEditor(e);
// 模态打开时禁用所有 Ctrl/Cmd 类快捷键,避免抢焦点或顶替对话框。
// 注意:编辑器内的 Ctrl+S/Ctrl+F 已经在 inEditor 分支里 return 了,
// 所以这里再补一个 modalOpen 不会影响 CM6 内部的快捷键。
if (modalOpen && !inEditor) {
// 但 Ctrl+S 仍允许 —— 在模态内按 Ctrl+S 不该有副作用
// 真正会被拦下的:Ctrl+, / Ctrl+F / Ctrl+Shift+T / Ctrl+E / Ctrl+N / Ctrl+Shift+F / Ctrl+Shift+A
// 用 toLowerCase() 同时覆盖 Ctrl 与 Cmd,按 Shift 时 key 已是 'F'/'T' 等大写
// —— 所以 'f' 一行就同时兜住 Ctrl+F 和 Ctrl+Shift+F,不用再写一份 shiftKey 子句。
const k = e.key.toLowerCase();
// P3 fix:用 Set 集中管理「modal 时需要吞掉的快捷键」白名单,
// 之前手写 6 个 k === X 链式条件,新增快捷键容易漏写一份。
// shiftKey-only 的 't' / 'a' 用 `${k}:shift` 作 key,加 shift 后缀区分。
const SUPPRESS_KEYS = new Set([
',',
'f',
't:shift',
'e',
'n',
'a:shift',
// M1 fix (audit):模态打开时拦截 Ctrl+W —— 否则 Electron 默认行为
// 是直接关闭窗口(带「未保存」的 settings dialog 也一起没了)。
// 'w' 无 shift 修饰,token 写法同 'f' / 'e' / 'n'。
'w',
]);
const token = e.shiftKey && (k === 't' || k === 'a') ? `${k}:shift` : k;
if (SUPPRESS_KEYS.has(token) && !e.altKey) {
e.preventDefault();
return;
}
}
// Ctrl/Cmd + F → 聚焦搜索(不带 Shift,让位给 Ctrl+Shift+F = 聚焦模式)
// 编辑器内让给 CM6 的查找面板(文内查找显然比跳到文件搜索更符合预期)
if ((e.key === 'f' || e.key === 'F') && !e.shiftKey) {
if (inEditor) return;
e.preventDefault();
if (onFocusSearch) onFocusSearch();
return;
}
// Ctrl/Cmd + , → 设置
if (e.key === ',') {
e.preventDefault();
if (onSettings) onSettings();
return;
}
// Ctrl/Cmd + Shift + T → 切换主题
if ((e.key === 'T' || e.key === 't') && e.shiftKey) {
e.preventDefault();
if (onToggleTheme) onToggleTheme();
return;
}
// Ctrl/Cmd + S → 保存(无 shift 修饰,避免误触)
// 编辑器内 CM6 的 Prec.highest keymap 已经调过 onSave 了,
// 这里再调一次会触发两次写盘 + 两个 toast。
if ((e.key === 's' || e.key === 'S') && !e.shiftKey && !e.altKey) {
if (inEditor) return;
e.preventDefault();
if (onSave) onSave();
return;
}
// Ctrl/Cmd + E → 切换视图模式(CM6 没占用,编辑器内也生效)
if ((e.key === 'e' || e.key === 'E') && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (onToggleEditorMode) onToggleEditorMode();
return;
}
// Ctrl/Cmd + N → 新建文件
// 注意:Ctrl+N 是 Electron「新建窗口」的传统快捷键,但本应用不提供多窗口,
// 直接复用为「新建文件」更符合笔记应用直觉。
if ((e.key === 'n' || e.key === 'N') && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (onNewFile) onNewFile();
return;
}
// Ctrl/Cmd + Shift + F → 聚焦模式(隐藏工具栏/侧栏/状态栏,纯阅读)
// 与 Ctrl+F 区别开 —— 后者是文内查找(编辑器内)/聚焦文件搜索(其它位置)。
if ((e.key === 'F' || e.key === 'f') && e.shiftKey && !e.altKey) {
e.preventDefault();
if (onToggleFocusMode) onToggleFocusMode();
return;
}
// Ctrl/Cmd + Shift + A → 切换 AI 修改面板(与聚焦模式同用 Shift 修饰,
// 避开 Ctrl+S/E/N/F/T 等已占用位置)
// C2 fix (audit):编辑器聚焦时让位给 CM6 的 keymap(Shift+A 在编辑器里是
// "全选" 之外的扩展键位),避免与编辑器内选择行为冲突。
if ((e.key === 'A' || e.key === 'a') && e.shiftKey && !e.altKey) {
if (inEditor) return;
e.preventDefault();
if (onToggleAi) onToggleAi();
return;
}
// Ctrl/Cmd + R → 重新加载界面(README 之前提到但未注册——audit 补全)。
// 与主菜单「重新加载」等价(两者都是 webContents.reload / location.reload)。
// 不走 modalOpen 抑制:模态挂死时用户可能就需要这个来强制刷新。
// 也不走 inEditor 抑制:CM6 没占 Ctrl+R。
if ((e.key === 'r' || e.key === 'R') && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (onReload) onReload();
return;
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}

260
src/splitter.js Normal file
View File

@@ -0,0 +1,260 @@
// 通用拖拽分割条工厂
// ============================================================================
//
// 起源(Stage 4d 抽出):src/app.js 里有三段几乎一模一样的 mountSplitter*:
// - mountSplitter (editor ↔ viewer,3480-3640)
// - mountSidebarSplitter (sidebar,3652-3850)
// - mountAiSplitter (AI panel,3865-3984)
//
// 三段共享的样板:
// - 鼠标 + 触屏 drag 状态机(dragging / pointerId / startX / startWidth)
// - cursor: col-resize + userSelect: none 切换
// - is-dragging class 切换
// - 命中区扩展(仅 sidebar / AI:±HIT_RADIUS 像素也算命中)
// - document 级 mousemove / touchmove / mouseup / touchend / touchcancel
//
// 不共享的部分(由调用方注入):
// - 边界 clamp:editor 走 frSpace 动态算,sidebar/AI 走静态 [MIN, MAX]
// - 持久化:splitRatio / sidebarWidth / aiWidth 三个字段
// - 命中区距离计算:sidebar 缓存中线(Phase M-H6),AI 每次重读
//
// 设计:
// - createSplitter(opts) 接受 handle / setSize / getSize / clampSize / onDragEnd
// 等回调;工厂不耦合业务(不知道是 px 还是 ratio,不知道写到 :root 还是
// .appShell),保持单一职责。
// - 内部维护自己挂的 listener 列表,dispose() 一次性解除 —— 调用方在 pagehide
// 兜底调一次,避免 15 个 document 级 listener 长期持有闭包。
// ============================================================================
/**
* @typedef {Object} HitZoneOpts
* @property {number} radius - 鼠标距 splitter 中线 ± radius 像素都算命中
* @property {() => boolean} [isAvailable] - 当前是否启用命中区(AI 关闭时返回 false)
* 默认 true
* @property {(coord: number) => number} distanceTo - 鼠标坐标 → 到 splitter 中线的距离
* (sidebar 缓存中线版本由调用方实现,工厂不关心怎么算)
* @property {(inZone: boolean) => void} [onHover] - 进/出命中区的视觉反馈
* 默认:改 document.body.style.cursor(仅 axis='x' → col-resize)
*/
/**
* @typedef {Object} SplitterOpts
* @property {HTMLElement} handle - 拖拽条元素
* @property {'x' | 'y'} [axis='x'] - 'x' = 水平拖动改宽度;'y' = 垂直拖动改高度
* @property {() => number} getSize - 当前目标元素的尺寸(px)
* 拖动期间会读多次(每次 mousemove 都会读);mount 时也会读一次确认初值
* @property {(px: number) => void} setSize - 把 px 写到目标(CSS 变量 / inline style)
* 调用方负责写哪儿(:root / .appShell / 元素自身)
* @property {(px: number) => number} clampSize - clamp 到合法范围(动态边界由调用方负责)
* @property {(px: number) => void} onDragEnd - 拖完调一次,参数是已 clamp 的 px
* 调用方负责持久化 / 反算 ratio / 弹错误提示
* @property {() => number} [initialSize] - 启动初始化。mount 时会用 setSize 应用一次
* @property {HTMLElement} [body] - 设 cursor/userSelect 的元素,默认 document.body
* @property {HitZoneOpts} [hitZone] - 扩展命中区(不传 = 不启用)
* @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners]
* 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。
* dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。
*/
/**
* 创建通用拖拽分割条。
*
* 鼠标 / 触屏 拖拽的语义与原三段实现一致:
* - mouse / touch 都能起拖;触屏用 touch.identifier 关联 touchmove/touchend
* - 拖动期间:document.body.style.cursor = 'col-resize' 或 'row-resize',
* userSelect = 'none';拖完恢复
* - 拖完一次:调 onDragEnd(clampSize(getSize())),由调用方持久化
*
* 命中区扩展(hitZone)语义:
* - document 级 capture mousedown:鼠标在 ±radius 内且目标不是 handle 自己,
* 启动拖动(capture 阶段确保在 file-list / editor 等之前拿到事件)
* - document 级 mousemove:拖动中按 axis 改尺寸;空闲时按 distanceTo 算 inZone,
* 通知 onHover 做视觉反馈(默认 cursor)
* - 调用方传 isAvailable 返回 false 时,整个命中区逻辑跳过(AI 关闭时)
*
* @param {SplitterOpts} opts
* @returns {{
* dispose: () => void,
* rebalance?: () => void,
* }}
* dispose 解除挂载的所有 listener(document 级 + handle 级 + window 级)
* rebalance 仅当传了 initialSize 时返回 —— 调用方在 window resize / layout 变化
* 时调,按最新 frSpace / DPI 重算尺寸(不影响持久化值)
*/
export function createSplitter(opts) {
const {
handle,
axis = 'x',
getSize,
setSize,
clampSize,
onDragEnd,
initialSize,
body = (typeof document !== 'undefined' ? document.body : null),
hitZone = null,
windowListeners = null,
} = opts;
if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素');
if (typeof getSize !== 'function') throw new Error('[splitter] getSize 必须是函数');
if (typeof setSize !== 'function') throw new Error('[splitter] setSize 必须是函数');
if (typeof clampSize !== 'function') throw new Error('[splitter] clampSize 必须是函数');
if (typeof onDragEnd !== 'function') throw new Error('[splitter] onDragEnd 必须是函数');
const cursor = axis === 'x' ? 'col-resize' : 'row-resize';
// axis 对应的 clientX/Y 字段名
const coordKey = axis === 'x' ? 'clientX' : 'clientY';
// 触屏坐标取 touches[i[]. 的 clientX/Y,命名与 coordKey 一致
/** 当前挂的所有 listener,dispose 时一次性解除 */
const listeners = [];
function addListener(target, type, listener, options) {
target.addEventListener(type, listener, options);
listeners.push({ target, type, listener, options });
}
// 拖拽状态机 —— 与原三段实现语义一致
let dragging = false;
let pointerId = null;
let startCoord = 0;
let startSize = 0;
function coordOf(eventLike) {
return eventLike[coordKey];
}
function startDrag(coord, id) {
dragging = true;
pointerId = id;
handle.classList.add('is-dragging');
startCoord = coord;
startSize = getSize();
if (body) {
body.style.cursor = cursor;
body.style.userSelect = 'none';
}
}
function applyDrag(coord) {
const dCoord = coord - startCoord;
setSize(clampSize(startSize + dCoord));
}
function endDrag() {
if (!dragging) return;
dragging = false;
pointerId = null;
handle.classList.remove('is-dragging');
if (body) {
body.style.cursor = '';
body.style.userSelect = '';
}
onDragEnd(clampSize(getSize()));
}
// ----- 自身 handle 上的 mousedown / touchstart -----
addListener(handle, 'mousedown', (e) => {
startDrag(coordOf(e), null);
e.preventDefault();
});
addListener(handle, 'touchstart', (e) => {
const t = e.touches[0];
if (!t) return;
startDrag(coordOf(t), t.identifier);
}, { passive: true });
// ----- document 级 mousemove / touchmove / ----end -----
addListener(document, 'mousemove', (e) => {
if (!dragging || pointerId !== null) return;
applyDrag(coordOf(e));
});
addListener(document, 'touchmove', (e) => {
if (!dragging || pointerId === null) return;
const t = Array.from(e.touches).find((x) => x.identifier === pointerId);
if (!t) return;
applyDrag(coordOf(t));
}, { passive: true });
addListener(document, 'mouseup', endDrag);
addListener(document, 'touchend', endDrag);
addListener(document, 'touchcancel', endDrag);
// ----- 命中区扩展(可选)-----
if (hitZone) {
const { radius, isAvailable = () => true, distanceTo, onHover } = hitZone;
// document 级 capture mousedown:捕获阶段拿到事件,避免被 file-list 等吞掉
addListener(document, 'mousedown', (e) => {
if (dragging) return;
if (e.button !== 0) return; // 只接左键
if (!isAvailable()) return;
// handle 自己 / handle 内子节点(含 reset 按钮等)不重入 —— 子节点自己
// 处理 mousedown(一般带 stopPropagation),否则会出现「点了按钮又拖动」。
if (handle.contains(e.target)) return;
if (distanceTo(coordOf(e)) > radius) return;
e.preventDefault();
e.stopPropagation();
startDrag(coordOf(e), null);
}, true);
// 拖动中 + 空闲命中区视觉反馈(合并到一个监听器,少派发一次)
let lastHover = null;
addListener(document, 'mousemove', (e) => {
if (dragging) {
if (pointerId === null) applyDrag(coordOf(e));
return;
}
if (!isAvailable()) return;
const inZone = distanceTo(coordOf(e)) <= radius;
if (inZone !== lastHover) {
lastHover = inZone;
if (onHover) onHover(inZone);
else if (body) body.style.cursor = inZone ? cursor : '';
}
});
}
// ----- 扩展 window 监听器(可选)-----
// 例如 sidebar splitter 用 resize / scroll 失效命中区中线缓存。
// 走 addListener 同一张表,dispose() 一次性解除。
if (windowListeners && windowListeners.length) {
for (const { type, listener, options } of windowListeners) {
addListener(window, type, listener, options);
}
}
// ----- 启动初始化 -----
if (typeof initialSize === 'function') {
setSize(clampSize(initialSize()));
}
// ----- 返回 -----
const result = {
/** 解除所有挂载的 listener。可重复调用。 */
dispose() {
for (const { target, type, listener, options } of listeners) {
try { target.removeEventListener(type, listener, options); } catch { /* ignore */ }
}
listeners.length = 0;
if (body) {
body.style.cursor = '';
body.style.userSelect = '';
}
handle.classList.remove('is-dragging');
},
};
// 仅当调用方传 initialSize 时才暴露 rebalance —— 让"窗口 resize 时按 frSpace 重算"
// 这类外部触发走单点真理(避免多个 mountSplitter 都各自挂 resize 监听器)。
if (typeof initialSize === 'function') {
result.rebalance = () => {
if (dragging) return;
setSize(clampSize(initialSize()));
};
}
return result;
}

76
src/stats.js Normal file
View File

@@ -0,0 +1,76 @@
// 文本统计 —— 词数 / 行数 / 字符数 的单一来源
//
// 单一来源原因:
// 之前 editor.getStats() 走 view.state.doc.lines(CM6 逻辑行),viewer.getStats()
// 走 source.split('\n').length —— 同一份内容在 preview / edit 模式下状态栏
// 「N 行」会跳变(例如尾部空行 / 长内容折行)。这里把行数算法收敛为:
// - 源文本 = source 给的(无论从 CM6 还是从 viewer 缓存)
// - 行数 = 非空行数(至少 1)
//
// CJK 分词算法保留 utils.countWords 的版本(已迭代到正确版本):
// - 中文 / 日文 / 韩文字符按字符计
// - 拉丁 / 数字按空白分词后再过滤纯标点
// - 不重叠计数
/**
* 统计 CJK 友好的词数。
* 实现细节见 utils.countWords(语义相同,只是换了模块)。
*
* @param {string} text
* @returns {number}
*/
export function countWords(text) {
if (!text || typeof text !== 'string') return 0;
const trimmed = text.trim();
if (!trimmed) return 0;
const cjkRe = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
const cjkCount = (trimmed.match(cjkRe) || []).length;
const latinPart = trimmed.replace(cjkRe, ' ');
const latinCount = (latinPart.match(/\S+/g) || [])
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
return cjkCount + latinCount;
}
/**
* 统一统计:基于「源文档」字符串同时给出 chars/words/lines。
*
* - chars = source.length(不含末尾 trimming;CM6 doc.toString() 不含末尾换行)
* - words = countWords(source)(CJK 单字 + 拉丁词)
* - lines = 非空行数(至少 1,纯空白内容 = 1 行)
*
* 用 source 而不是 bodyElement.textContent:
* 切预览 / 编辑时状态栏「N 行」不会跳变。textContent 是渲染后的纯文本,
* 与 markdown 源(特别是 HTML / 列表 / 代码块)数量上不同。
*
* @param {string} source
* @returns {{chars:number, words:number, lines:number}}
*/
export function getTextStats(source) {
const text = typeof source === 'string' ? source : '';
if (!text) return { chars: 0, words: 0, lines: 0 };
// 非空行数:split 出所有行,去掉纯空白,再取长度。
// 纯空白内容(只有 \n / 空格)→ 0 行,让 formatStatusStats 走「0 词」分支。
// 之前用 `|| 1` 把「空」和「刚好 1 行内容」都映射成 1,新打开的空白文件
// 状态栏立刻显示「1 行」误导用户以为已有内容(audit L8)。
const lines = text.split('\n').filter((ln) => ln.trim().length > 0).length;
return {
chars: text.length,
words: countWords(text),
lines,
};
}
/**
* 渲染状态栏 chip 文案。
* - 0 行 / 0 词时不显示(由 caller 决定)
* - 单文件被删或为空 → `${words} 词`
* - 正常 → `${words} 词 · ${lines} 行`
*
* @param {{words:number, lines:number}} stats
* @returns {string}
*/
export function formatStatusStats(stats) {
const words = (stats.words || 0).toLocaleString('zh-CN');
const lines = stats.lines || 0;
return lines > 0 ? `${words} 词 · ${lines} 行` : `${words} 词`;
}

18
src/styles.css Normal file
View File

@@ -0,0 +1,18 @@
/* ===========================================================================
Notes — 样式入口(Stage 4a:分片)
通过 @import 按顺序串联 01..12。
浏览器原生 CSS @import 性能成本可忽略(12 个文件并行或合并);本应用零打包。
=========================================================================== */
@import url('./styles/01-tokens.css');
@import url('./styles/02-palettes.css');
@import url('./styles/03-base.css');
@import url('./styles/04-toolbar.css');
@import url('./styles/05-sidebar.css');
@import url('./styles/06-markdown.css');
@import url('./styles/07-statusbar.css');
@import url('./styles/08-modal.css');
@import url('./styles/09-misc.css');
@import url('./styles/10-a11y.css');
@import url('./styles/11-ai.css');
@import url('./styles/12-print.css');

105
src/styles/01-tokens.css Normal file
View File

@@ -0,0 +1,105 @@
/* ===========================================================================
Design Tokens
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ===========================================================================
Notes — 全局样式
设计理念:现代简约 + 排版优先
- 设计令牌:间距、字号、颜色、阴影、动画曲线统一管理
- 浅色 / 深色主题:暖色调低饱和度,长时间阅读舒适
- 排版:行高 1.85、字号 16px、优化的中英文字体栈
- 组件:浮动卡片、柔和阴影、克制的圆角
=========================================================================== */
/* ---------- Design Tokens ---------- */
:root {
/* 间距系统(基于 4 的倍数) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-9: 48px;
/* 圆角 */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px; /* 仅 .modal 消费 (styles.css:1892) */
/* 字号(基于 1.2 比例)
--fs-base 14 → 15:UI 文字比 markdown 正文小一档会看着发「瘦」;
提到 15px 后配合 font-weight: 500 可读性显著提升,但只 +1px,
不破坏现有 52px 工具栏 / 38px 状态栏的纵向布局 */
--fs-xs: 11px;
--fs-xs-readable: 12px; /* 11px 在低 DPI/小屏幕上易"糊",settings 对话框内的 hint / 副标题用它 */
--fs-sm: 13px;
--fs-base: 15px;
--fs-lg: 17px;
--fs-xl: 20px;
--fs-2xl: 24px;
--fs-4xl: 38px;
/* 行高 */
--lh-snug: 1.4;
--lh-normal: 1.55;
--lh-relaxed: 1.75;
--lh-loose: 1.85;
/* 字距 */
--tracking-tight: -0.01em;
--tracking-wide: 0.02em;
/* 字体栈:英文优先 Inter,中文用系统字体保性能 */
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Helvetica Neue", Helvetica, Arial, sans-serif;
/* 阅读字体(用于 markdown 正文):CJK 优先 */
--font-reading:
"Source Serif Pro", "Noto Serif CJK SC", "Source Han Serif SC",
"Songti SC", "STSong", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", serif;
/* 等宽字体:JetBrains Mono 优先,回落到系统等宽 */
--font-mono:
"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular,
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* 高度 */
--h-toolbar: 52px;
--h-statusbar: 38px;
--w-sidebar: 220px;
--sidebar-width: var(--w-sidebar);
--w-sidebar-narrow: 200px;
--w-reader-max: 720px;
/* 阅读排版:默认值与现有 --fs-lg / --lh-loose 同步;
在 <html> 行内 style 上由 renderer 动态覆盖,切主题不会重置 */
--reader-font-size: var(--fs-lg);
--reader-line-height: var(--lh-loose);
/* Z-index */
--z-sidebar: 5;
--z-toolbar: 10;
--z-statusbar: 10;
--z-modal: 1000;
--z-toast: 1100;
--z-boot-error: 1200; /* 比 toast 高一档,确保启动错误盖在所有内容之上 */
/* 过渡曲线 */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--transition-fast: 120ms var(--ease-out);
--transition-normal: 200ms var(--ease-out);
--transition-slow: 320ms var(--ease-out);
/* 遮罩(modal / drawer / popover 背后的暗化层) —— 与主题正交,
light 主题用更轻的 alpha,否则会把 modal 卡片压成灰底破坏文字对比 */
--scrim: rgba(0, 0, 0, 0.5);
--scrim-soft: rgba(0, 0, 0, 0.12);
}

500
src/styles/02-palettes.css Normal file
View File

@@ -0,0 +1,500 @@
/* ===========================================================================
Palettes × Themes —— 收敛表达
Stage 4a.2:用「主题骨架 + palette 差异补丁」替代原 10 块字符级复制
设计思路:
所有 palette × theme 共享同一组 token 表达(bg-hover / bg-active / 阴影
/ scrollbar / 边框等),但每个 palette 在每个 theme 下各自有自己的
视觉个性(accent 色、带色相的 bg / fg)。
所以分两层:
1. 主题骨架(深 / 浅):定义所有「主题相关但 palette 无感」的 token。
主题骨架只装「公共部分」,不装任何 palette 个性。
2. palette 差异补丁:每块只覆盖该 palette 的 accent 系列 + 带色相的
bg/fg/text + markdown 配色 + toolbar-divider。其余 token 走主题骨架。
选择器特异性:
- [data-theme="dark"] (0,1,1)
- [data-theme-palette="X"][data-theme="Y"] (0,2,0)
后者更高,所以 palette 块能覆盖主题骨架。
=========================================================================== */
/* ---------- Dark Theme Skeleton ----------
暗色通用 token —— 5 个 palette 的暗色版共享这些。
palette 块只覆盖带色相部分(accent + 自身 bg/fg/md-*)。
*/
[data-theme="dark"] {
/* 背景交互态:所有暗色 palette 都用白叠加 */
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-active: rgba(255, 255, 255, 0.08);
/* 边框:所有暗色 palette 浅边用白叠加、强边用更深的白 */
--border-subtle: rgba(255, 255, 255, 0.04);
--border-strong: rgba(255, 255, 255, 0.12);
/* 滚动条 */
--scrollbar-thumb: rgba(255, 255, 255, 0.10);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.20);
/* 阴影:暗色下更深 */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.32), 0 2px 4px rgba(0, 0, 0, 0.16);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.42), 0 4px 8px rgba(0, 0, 0, 0.25);
/* --shadow-glow 由 palette 设(带 accent-soft) */
/* 语义色:所有暗色 palette 统一
audit fix (H4/H5):--danger 由 #ff6b6b → #d23636(white-on-bg 对比从 2.2:1
提到 4.86:1,AA 通过)。btn-danger / error chip 都用这个 token,统一收敛。 */
--danger: #d23636;
--btn-on-danger: #ffffff;
--warning: #ffb454;
--success: #5ad19a;
/* Markdown 通用(不带色相):所有暗色 palette 都用白叠加 */
--md-table-border: rgba(255, 255, 255, 0.08);
--md-table-stripe: rgba(255, 255, 255, 0.02);
--md-table-header-bg: rgba(255, 255, 255, 0.04);
--md-hr: rgba(255, 255, 255, 0.08);
/* --md-blockquote-* / --md-inline-code-bg / --md-code-bg / --md-link 由 palette 设 */
--md-heading: var(--fg-primary);
/* ── 文字版语义色 ───────────────────────────────────────────────
用途:状态栏 chip 字、AI 错误小字、表单 hint、设置卡 selected label
等 11-14px 的有色正文。装饰色(warning / danger / success / accent)
为图标、边框、圆点、单字符(AI diff +/-)设计,直接套到正文字符上
会因高彩度小字"振动"看着累。这里取 60-65% 装饰色 + 35-40%
fg-primary 调和:色相仍可识别(暖橙 / 红 / 绿 / 蓝调),但 chroma
降到不会刺眼的程度。
比例选择:
dark 用 60% 装饰色 —— 深底 fg-primary 偏白,混出"暖白"效果。
light 用 70% 装饰色 —— 浅底 fg-primary 偏黑,需要更高的色比保证
文字可见度,70% 仍比 100% 纯色温和一档。
边界:borders / dots / icons / 单字符 +/- 继续用纯 *(不退色),
详见各组件 CSS 注释。 */
--warning-text: color-mix(in srgb, var(--warning) 60%, var(--fg-primary));
--danger-text: color-mix(in srgb, var(--danger) 60%, var(--fg-primary));
--accent-text: color-mix(in srgb, var(--accent) 65%, var(--fg-primary));
}
/* ---------- Light Theme Skeleton ---------- */
[data-theme="light"] {
--bg-hover: rgba(0, 0, 0, 0.04);
--bg-active: rgba(0, 0, 0, 0.07);
--border-subtle: rgba(0, 0, 0, 0.04);
--border-strong: rgba(0, 0, 0, 0.14);
--scrollbar-thumb: rgba(0, 0, 0, 0.14);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.26);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.035), 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.035);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.09), 0 4px 12px rgba(0, 0, 0, 0.05);
/* --shadow-glow 由 palette 设(带 accent-soft) */
/* audit fix (H4/H5):light --danger 由 #e54848 → #c52424(white-on-bg 对比从
3.93:1 提到 5.84:1,AA 通过)。--warning / --success 本身对比足够,未动。 */
--danger: #c52424;
--btn-on-danger: #ffffff;
--warning: #d97706;
--success: #10b981;
--md-table-border: rgba(0, 0, 0, 0.08);
--md-table-stripe: rgba(0, 0, 0, 0.02);
--md-table-header-bg: rgba(0, 0, 0, 0.03);
--md-hr: rgba(0, 0, 0, 0.08);
--md-heading: var(--fg-primary);
/* 文字版语义色(与 dark 段对应注释)。light 底 fg-primary 偏黑,
用 70% 装饰色让色相仍清晰,但比纯色温和:避免红字"扎眼"、橙字"泛酸"。 */
--warning-text: color-mix(in srgb, var(--warning) 70%, var(--fg-primary));
--danger-text: color-mix(in srgb, var(--danger) 70%, var(--fg-primary));
--accent-text: color-mix(in srgb, var(--accent) 70%, var(--fg-primary));
}
/* ===========================================================================
Palette overrides —— 每个块只覆盖该 palette 个性化的 token
=========================================================================== */
/* ---------- Default (Linear 风格:近黑中性底 + indigo) ----------
兜底:body 上没有 data-theme-palette 时也应用 default(兼容未挂属性场景)。 */
body:not([data-theme-palette]),
[data-theme-palette="default"][data-theme="dark"] {
--bg-app: #08090a;
--bg-toolbar: rgba(8, 9, 10, 0.85);
--bg-sidebar: rgba(255, 255, 255, 0.022);
--bg-viewer: #08090a;
--bg-elevated: #16171a;
--bg-card: #101113;
--bg-card-hover: #191a1d;
--fg-primary: #f7f8f8;
--fg-secondary: #b0b6c0;
--fg-tertiary: #8e95a1;
--fg-muted: #6c7178;
--border-default: rgba(255, 255, 255, 0.06);
/* audit fix (H4/H5):--accent 由 #7883e0 → #4f5ec7(white-on-bg 对比从 3.46:1
提到 5.6:1,AA 通过)。其它 indigo 调色板都是浅 accent 配深色字(--btn-on-accent
= #1a232e),已经满足 AA,无需调。 */
--accent: #4f5ec7;
--btn-on-accent: #ffffff;
--accent-hover: #6473d3;
--accent-soft: rgba(79, 94, 199, 0.22);
--accent-soft-hover: rgba(79, 94, 199, 0.28);
--accent-selection: rgba(79, 94, 199, 0.50);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #101113;
--md-code-text: #e6e9f1;
--md-inline-code-bg: rgba(120, 131, 224, 0.20);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
/* indigo 在近黑底只有 4.2:1,链接调亮一档(2026-08 进一步提亮到 ~7:1) */
--md-link: #a3aaef;
--md-link-hover: #b8bef4;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(255, 255, 255, 0.08) 50%, transparent 100%);
}
[data-theme-palette="default"][data-theme="light"] {
--bg-app: #fbfbfc;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #f4f5f8;
--bg-viewer: #fbfbfc;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f7f8f9;
--fg-primary: #282a30;
--fg-secondary: #525968;
--fg-tertiary: #676e7d;
--fg-muted: #767b83;
--border-default: rgba(0, 0, 0, 0.07);
--accent: #5e6ad2;
--btn-on-accent: #ffffff;
--accent-hover: #4d58bd;
--accent-soft: rgba(94, 106, 210, 0.12);
--accent-soft-hover: rgba(94, 106, 210, 0.16);
--accent-selection: rgba(94, 106, 210, 0.28);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #f4f5f8;
--md-code-text: #282a30;
--md-inline-code-bg: rgba(94, 106, 210, 0.10);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
/* light 主题的 --md-link 用比 accent 更深的色:modal 卡片底色浅(#eceefa / #e2eff5 / #e6f1ea / #faeae7),
直接用 accent 只剩 3.5-4.0:1,不达 WCAG AA(4.5:1)。这里给每个 light 调色板独立加深。 */
--md-link: #4244a8;
--md-link-hover: #2f3082;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(0, 0, 0, 0.08) 50%, transparent 100%);
}
/* ---------- Ocean (深海军 + 青绿) ---------- */
[data-theme-palette="ocean"][data-theme="dark"] {
--bg-app: #0b141d;
--bg-toolbar: rgba(11, 20, 29, 0.85);
--bg-sidebar: rgba(180, 210, 235, 0.04);
--bg-viewer: #0b141d;
--bg-elevated: #131e2a;
--bg-card: #16212e;
--bg-card-hover: #1c2837;
--fg-primary: #e3ecf3;
--fg-secondary: #c4d2dc;
--fg-tertiary: #96b1c6;
--fg-muted: #6c8090;
--border-default: rgba(180, 210, 235, 0.08);
--accent: #6cc5e6;
--btn-on-accent: #1a232e;
--accent-hover: #85d2ee;
--accent-soft: rgba(108, 197, 230, 0.20);
--accent-soft-hover: rgba(108, 197, 230, 0.26);
--accent-selection: rgba(108, 197, 230, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #111923;
--md-code-text: #cbd6e0;
--md-inline-code-bg: rgba(108, 197, 230, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #85d2ee;
--md-link-hover: #9cdcf2;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(180, 210, 235, 0.08) 50%, transparent 100%);
}
[data-theme-palette="ocean"][data-theme="light"] {
--bg-app: #eef4f8;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #e5eef5;
--bg-viewer: #eef4f8;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #eef4f8;
--fg-primary: #0f1d2a;
--fg-secondary: #34465a;
--fg-tertiary: #4f697a;
--fg-muted: #6a7d8d;
--border-default: rgba(15, 40, 70, 0.08);
--accent: #0e7da8;
--btn-on-accent: #ffffff;
--accent-hover: #0a688d;
--accent-soft: rgba(14, 125, 168, 0.12);
--accent-soft-hover: rgba(14, 125, 168, 0.16);
--accent-selection: rgba(14, 125, 168, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #e7eef4;
--md-code-text: #0f1d2a;
--md-inline-code-bg: rgba(14, 125, 168, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #084060;
--md-link-hover: #05304a;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(15, 40, 70, 0.08) 50%, transparent 100%);
}
/* ---------- Forest (松黑 + 翠玉) ---------- */
[data-theme-palette="forest"][data-theme="dark"] {
--bg-app: #0e1410;
--bg-toolbar: rgba(14, 20, 16, 0.85);
--bg-sidebar: rgba(180, 210, 190, 0.04);
--bg-viewer: #0e1410;
--bg-elevated: #16201a;
--bg-card: #19251e;
--bg-card-hover: #1f2d26;
--fg-primary: #e6efe8;
--fg-secondary: #c4d4ca;
--fg-tertiary: #9eb3a4;
--fg-muted: #6c7d72;
--border-default: rgba(180, 210, 190, 0.08);
--accent: #76c78d;
--btn-on-accent: #1a232e;
--accent-hover: #8dd4a3;
--accent-soft: rgba(118, 199, 141, 0.20);
--accent-soft-hover: rgba(118, 199, 141, 0.26);
--accent-selection: rgba(118, 199, 141, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #141c16;
--md-code-text: #cad7ce;
--md-inline-code-bg: rgba(118, 199, 141, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #8dd4a3;
--md-link-hover: #a4e0b9;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(180, 210, 190, 0.08) 50%, transparent 100%);
}
[data-theme-palette="forest"][data-theme="light"] {
--bg-app: #f0f5f1;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #e6efe9;
--bg-viewer: #f0f5f1;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f0f5f1;
--fg-primary: #102018;
--fg-secondary: #34483c;
--fg-tertiary: #4f6356;
--fg-muted: #6d7e73;
--border-default: rgba(20, 55, 30, 0.08);
--accent: #2c8c52;
--btn-on-accent: #000000;
--accent-hover: #227544;
--accent-soft: rgba(44, 140, 82, 0.12);
--accent-soft-hover: rgba(44, 140, 82, 0.16);
--accent-selection: rgba(44, 140, 82, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #e8efe9;
--md-code-text: #102018;
--md-inline-code-bg: rgba(44, 140, 82, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #1a5a30;
--md-link-hover: #0f4220;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(20, 55, 30, 0.08) 50%, transparent 100%);
}
/* ---------- Lavender (茄紫 + 粉紫) ---------- */
[data-theme-palette="lavender"][data-theme="dark"] {
--bg-app: #14101a;
--bg-toolbar: rgba(20, 16, 26, 0.85);
--bg-sidebar: rgba(200, 180, 220, 0.04);
--bg-viewer: #14101a;
--bg-elevated: #1d1726;
--bg-card: #211a2c;
--bg-card-hover: #282034;
--fg-primary: #ece6f2;
--fg-secondary: #d0c5dc;
--fg-tertiary: #a99cc0;
--fg-muted: #76688c;
--border-default: rgba(200, 180, 220, 0.08);
--accent: #c89ce8;
--btn-on-accent: #1a232e;
--accent-hover: #d4abee;
--accent-soft: rgba(200, 156, 232, 0.20);
--accent-soft-hover: rgba(200, 156, 232, 0.26);
--accent-selection: rgba(200, 156, 232, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #1a1423;
--md-code-text: #d3cce0;
--md-inline-code-bg: rgba(200, 156, 232, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #d4abee;
--md-link-hover: #e0baf3;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(200, 180, 220, 0.08) 50%, transparent 100%);
}
[data-theme-palette="lavender"][data-theme="light"] {
--bg-app: #f5f0fa;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #ebe4f3;
--bg-viewer: #f5f0fa;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f5f0fa;
--fg-primary: #1a1228;
--fg-secondary: #3d3252;
--fg-tertiary: #5d4f7a;
--fg-muted: #6c5f86;
--border-default: rgba(60, 30, 90, 0.08);
--accent: #7c3aaf;
--btn-on-accent: #ffffff;
--accent-hover: #6a2f95;
--accent-soft: rgba(124, 58, 175, 0.12);
--accent-soft-hover: rgba(124, 58, 175, 0.16);
--accent-selection: rgba(124, 58, 175, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #ebe2f2;
--md-code-text: #1a1228;
--md-inline-code-bg: rgba(124, 58, 175, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #5e1f8a;
--md-link-hover: #421560;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(60, 30, 90, 0.08) 50%, transparent 100%);
}
/* ---------- Sunset (酒红 + 桃粉) ---------- */
[data-theme-palette="sunset"][data-theme="dark"] {
--bg-app: #1a0e14;
--bg-toolbar: rgba(26, 14, 20, 0.85);
--bg-sidebar: rgba(220, 170, 180, 0.04);
--bg-viewer: #1a0e14;
--bg-elevated: #261520;
--bg-card: #2c1a26;
--bg-card-hover: #34212e;
--fg-primary: #f4e6ea;
--fg-secondary: #d8c5cc;
--fg-tertiary: #b598a8;
--fg-muted: #806878;
--border-default: rgba(220, 170, 180, 0.08);
--accent: #ffa091;
--btn-on-accent: #1a232e;
--accent-hover: #ffb1a4;
--accent-soft: rgba(255, 160, 145, 0.20);
--accent-soft-hover: rgba(255, 160, 145, 0.26);
--accent-selection: rgba(255, 160, 145, 0.48);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #20131a;
--md-code-text: #dccdd2;
--md-inline-code-bg: rgba(255, 160, 145, 0.18);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #ffb1a4;
--md-link-hover: #ffc2b6;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(220, 170, 180, 0.08) 50%, transparent 100%);
}
[data-theme-palette="sunset"][data-theme="light"] {
--bg-app: #fbf2ee;
--bg-toolbar: rgba(255, 255, 255, 0.85);
--bg-sidebar: #f3e6e1;
--bg-viewer: #fbf2ee;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #fbf2ee;
--fg-primary: #2a121a;
--fg-secondary: #48303a;
--fg-tertiary: #6b4d58;
--fg-muted: #7b636c;
--border-default: rgba(80, 30, 40, 0.08);
--accent: #d94f3a;
--btn-on-accent: #000000;
--accent-hover: #bf3f2d;
--accent-soft: rgba(217, 79, 58, 0.12);
--accent-soft-hover: rgba(217, 79, 58, 0.16);
--accent-selection: rgba(217, 79, 58, 0.30);
--shadow-glow: 0 0 0 3px var(--accent-soft);
--md-code-bg: #f1e3dd;
--md-code-text: #2a121a;
--md-inline-code-bg: rgba(217, 79, 58, 0.08);
--md-blockquote-border: var(--accent);
--md-blockquote-bg: var(--accent-soft);
--md-link: #8e1f10;
--md-link-hover: #621208;
--toolbar-divider: linear-gradient(90deg,
transparent 0%, rgba(80, 30, 40, 0.08) 50%, transparent 100%);
}

159
src/styles/03-base.css Normal file
View File

@@ -0,0 +1,159 @@
/* ===========================================================================
Reset + Focus Visibility
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ---------- Reset ---------- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
-webkit-text-size-adjust: 100%;
}
/*
长文件名截断(fix:长文件名导致 toolbar 偏移):
- html/body 是顶层容器,需要 min-width:0 防止内部 grid item 的 min-content
把整个 grid 撑得比视口宽(虽然 .toolbar 自己已加 min-width:0,但 body grid
在 grid-template-columns 未指定的情况下默认按 max-content 自适应,多加
一层防御能避免任何子 grid item 把 body 撑出去)
*/
html, body { min-width: 0; }
body {
font-family: var(--font-sans);
font-size: var(--fs-base);
line-height: var(--lh-normal);
color: var(--fg-primary);
background: var(--bg-app);
/* font-weight: 500 全局提升,让 UI 文字(statusbar / file-meta / 空状态等
没有显式 font-weight 的元素)默认就更厚重,
解决「看着发淡」的问题。Markdown body 等显式 400 的不受影响 */
font-weight: 500;
/* 字体平滑策略:默认开启 ClearType(subpixel-antialiased),
在 Windows LCD 上比 grayscale 抗锯齿更锐利、字形更黑。
旧值 antialiased 在 Chromium 上会强制关闭 ClearType,反而让字变细变糊。
Retina / 高 DPI 屏幕下用下方媒体查询切回 antialiased 避免色边 */
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: optimizeLegibility;
font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "cv11" 1;
/* 让浏览器能在字体缺少对应字重时合成粗体(如 system Segoe UI 落到 600),
避免某些 weight 实际渲染为 400 看着发淡 */
font-synthesis: weight;
user-select: none;
display: grid;
/* 四行:顶部工具栏 / 主区 / AI 对话 dock / 状态栏;fr 单位随窗口缩放自适应。
AI dock 默认 hidden(auto 高度 = 0);展开时挤占 app-shell 高度,
状态栏始终钉在最底。 */
grid-template-rows: var(--h-toolbar) 1fr auto var(--h-statusbar);
/*
长文件名截断(fix:长文件名导致 toolbar 偏移):
- 单列 grid 显式声明 minmax(0, 1fr) 防止任何 grid item 的 min-content
把整列撑得比视口宽(虽然 .toolbar 自己已加 min-width:0,但显式 1fr
让浏览器明确按可用空间分配,防御性更好)
*/
grid-template-columns: minmax(0, 1fr);
/* 防止内容溢出导致整个 body 高度超过视口,破坏 grid */
height: 100vh;
overflow: hidden;
/* 切主题时让背景/前景色平滑过渡(避免一帧硬切) */
transition: background var(--transition-slow), color var(--transition-slow);
}
/* 高 DPI / Retina 屏幕:关闭 subpixel,否则字形边缘出现红蓝色彩边看着脏。
非高 DPI 屏幕(普通 LCD)保留 subpixel,字形更黑更锐利 */
@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* 把四个「chrome」元素显式钉到对应 grid 行 —— 防御性。
即使 DOM 里在它们之前有其他 grid 子元素(如 .focus-hotzone 在某些路径下
被加进 grid 流),也不会错位。
.ai-chat-dock 默认 hidden;展开时占据第 3 行(auto),状态栏被推到第 4 行。 */
.toolbar { grid-row: 1; }
.app-shell { grid-row: 2; }
.ai-chat-dock { grid-row: 3; }
.statusbar { grid-row: 4; }
/* [hidden] 必须真正生效:
浏览器 UA 默认 [hidden] { display: none } 与 .status-chip { display: inline-flex }
同特异性 (0,1,0),用户样式后写胜出 → 隐藏时仍占位、推挤布局。
状态栏的 status-stats / status-heading 一旦被设 [hidden] 还会拉伸 17.5px,
把右侧 chip 簇向右推;其他模块也有类似问题。统一加 !important 兜底。 */
[hidden] {
display: none !important;
}
/* ---------- 键盘焦点可见性 ----------
- 鼠标点击不应画外圈:浏览器原生行为 (Chromium / WebKit 已支持 :focus-visible)
- 键盘 Tab 走到哪儿都该看到一个 2px accent 环
- 不破坏现有「假轮廓」元素(.search-input / .form-input):它们各自有
:not(:focus-visible) 块把鼠标态的 box-shadow 收回,外圈再叠加
*/
:where(a, button, [role="option"], [tabindex], input, select, textarea, summary, label):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: inherit;
}
/* 列表项的 outline 不需要 offset,否则会贴住左侧色条 */
.file-item:focus-visible {
outline-offset: -2px;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
a {
/* 全局链接默认色:用 --accent-text(65% 装饰色 + 35% fg-primary)。
之前纯 --accent 在 13-14px 链接上"扎眼":锚点链接有时是 4-5 字短词,
在一片中性正文里像贴了荧光贴纸。markdown-body 自己的链接走 --md-link
(更亮一档),全局默认行内/工具栏链接走这一档,视觉权重一致 */
color: var(--accent-text);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
/* hover 提亮一档:从文字版(--accent-text)→ 纯 --accent,给出"可点"的明确反馈 */
color: var(--accent);
}
input {
font: inherit;
color: inherit;
}
ul {
list-style: none;
}
code, pre, kbd {
font-family: var(--font-mono);
user-select: text;
}
::selection {
/* 用 --accent-selection(alpha 0.28-0.50)而非 --accent-soft(0.12-0.22):
旧值在亮色主题上 alpha 仅 0.12,选区看起来像没选中,新建笔记标题、
重命名、设置输入框、AI dock 等所有 input 的文字选中态都受影响。
CM 编辑器早就走 --accent-selection(见 src/editor-theme.js),全局规则
跟它对齐,保证 input 内 / 编辑器内 / Markdown viewer 任意位置选中态一致 */
background: var(--accent-selection);
color: var(--fg-primary);
}

565
src/styles/04-toolbar.css Normal file
View File

@@ -0,0 +1,565 @@
/* ===========================================================================
Toolbar + App Shell
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ============================================================
Toolbar (custom titlebar)
============================================================ */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--h-toolbar);
/* 右侧用更小的内边距,避免在默认 1200px 窗口下按钮组被裁;
顶部标题区仍保留 --space-5 的呼吸感 */
padding: 0 4px 0 var(--space-5);
background: var(--bg-toolbar);
/* audit fix (P2):去掉 backdrop-filter —— toolbar 在 bg-toolbar 实色之上,
毛玻璃的 saturate(180%) blur(20px) 强制把整条 1200×40 px 区域放到独立
compositor layer,且每帧滚动主内容时 GPU 都要重算这条带覆盖区域的模糊。
视觉上完全无差异(实色背景上看不出毛玻璃),但每次滚动都是几十 KB 像素的
重复 GPU 工作。 */
border-bottom: 1px solid var(--border-subtle);
z-index: var(--z-toolbar);
position: relative;
/*
长文件名截断(fix:长文件名导致 toolbar 偏移):
- toolbar 是 body grid 子元素,min-width 默认是 auto = min-content。
.app-title-text 的 white-space:nowrap + 长文本让 min-content 极大,
toolbar 默认会撑到比视口还宽,把 .toolbar-right(关闭等按钮)挤出视口。
- min-width:0 覆盖默认 min-content,让 toolbar 接受 body grid 给它的宽度
- overflow:hidden 兜底:万一有其他内容溢出,clip 在 toolbar 内部而不是溢出到 body
(body 也是 overflow:hidden,但那是最后一道防线,视觉上能看到 toolbar 自身的偏移)
*/
min-width: 0;
overflow: hidden;
}
.toolbar::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 1px;
background: var(--toolbar-divider);
}
.drag-region {
-webkit-app-region: drag;
flex: 1;
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
height: 100%;
padding-left: 2px;
}
.no-drag {
-webkit-app-region: no-drag;
display: flex;
align-items: center;
gap: var(--space-1);
}
/* 工具栏分组:将同一类别的按钮聚拢(视觉相邻),组间用 divider 隔开 */
.toolbar-group {
display: inline-flex;
align-items: center;
gap: 2px;
}
.toolbar-divider {
display: inline-block;
width: 1px;
height: 18px;
/* 用半透明白色渐变,比纯色 border 更精致;亮主题用对应浅色 */
background: linear-gradient(180deg,
transparent 0%,
var(--border-default) 25%,
var(--border-default) 75%,
transparent 100%);
/* 从 6px 减到 5px:默认 1200px 窗口下 6 个分组 + 7 个 divider 累计 84px gap,
改 5px 后 70px,让工具栏横向更紧凑 */
margin: 0 5px;
flex-shrink: 0;
}
.app-title {
font-size: var(--fs-base);
font-weight: 600;
color: var(--fg-primary);
letter-spacing: var(--tracking-wide);
display: flex;
align-items: center;
gap: 10px;
/* 选中态:避免拖拽时被高亮 */
user-select: none;
/*
长文件名截断(fix:长文件名导致 toolbar 偏移):
- min-width:0 是关键 —— flex 容器默认 min-content 是整段文本,
没有它 .app-title 会拒绝收缩,把 .toolbar-right(按钮组)挤出视口右侧
- overflow:hidden 让超出部分不可见,配合 .app-title-text 的 ellipsis 截断
*/
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
/*
长文件名截断(fix:长文件名导致 toolbar 偏移):
- 真正承载文本的子 span —— 直接文本节点在 flex 容器里无法触发 ellipsis
- min-width:0 + flex:1 1 auto 让它优先吃光可用空间,超出才走截断
- text-overflow:ellipsis + white-space:nowrap 让长文件名从右侧省略
*/
.app-title-text {
display: block;
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-title::before {
content: "";
width: 20px;
height: 20px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
/* 固定图标尺寸,不参与收缩(flex-shrink:0) */
flex: 0 0 auto;
}
/* 应用图标(来自 icon.ico 的 PNG data URL):
- 20×20 + 6px 圆角,与 .app-title::before 占位形状一致,替换它而不是叠加
- draggable="false" 防选中后拖出浏览器(默认 <img> 选中状态可拖)
- 与 .app-title 共享同一行(display:flex 在 .toolbar-left) */
.app-icon {
width: 20px;
height: 20px;
border-radius: 6px;
flex: 0 0 auto;
/* 与 ::before 占位的多层 box-shadow 对齐,亮 / 暗主题下都不漂浮 */
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
/* 真图标加载成功 → 隐藏 .app-title::before 占位,避免双图标 */
body.has-app-icon .app-title::before {
display: none;
}
/* Toolbar 按钮:图标按钮 + 悬浮效果
默认色用 secondary(暗色 ~8.5:1, 亮色 ~8:1),
旧值 secondary 在暗色仅 ~5.5:1,加上 13px 字号看着发灰,2026-08 统一提亮 */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
color: var(--fg-secondary);
/* 加 transition 阴影和位移,让 hover 反馈更细腻 */
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--fg-primary);
}
.btn-icon:active {
/* scale 从 0.94 减到 0.96:原值 0.94 在 34px 按钮上缩 2px,
视觉跳动明显;0.96 只缩 1px,反馈更克制 */
background: var(--bg-active);
transform: scale(0.96);
}
.btn-icon.is-active {
background: var(--accent-soft);
color: var(--accent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
.btn-icon.is-active:hover {
background: var(--accent-soft-hover);
}
.window-controls {
display: flex;
align-items: center;
margin-left: 4px;
/* 让最右侧的关闭按钮贴齐视口右沿(沿用平台常见 UX),但避免裁掉 hover 区;
之前用 -8px 时会把按钮裁进视口外 8px。改为 -2px 把贴边视觉压回视口内 */
margin-right: -2px;
gap: 0;
/* 用渐变分隔线替代纯色 —— 比 border 精致,符合 macOS 视觉 */
position: relative;
}
.window-controls::before {
content: "";
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
height: 20px;
width: 1px;
background: linear-gradient(180deg,
transparent 0%,
var(--border-default) 25%,
var(--border-default) 75%,
transparent 100%);
}
.btn-window {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
/* 同 .btn-icon:用 secondary 替代 tertiary,避免暗色下窗口控制按钮发灰 */
color: var(--fg-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
}
.btn-window:hover {
background: var(--bg-hover);
color: var(--fg-primary);
}
.btn-window.btn-close:hover {
background: var(--danger);
color: var(--btn-on-danger, #ffffff);
}
/* 工具栏按钮组(视图模式三连按钮) */
.btn-group {
display: inline-flex;
align-items: center;
/* 按钮组之间的间距由 .toolbar-divider 提供,这里去掉额外 margin-left */
margin-left: 0;
gap: 1px;
/* padding: 2px + 按钮 30px = 34px,与外层独立 .btn-icon 同高,保证视觉对齐 */
padding: 2px;
/* 容器底色比 hover 更轻,避免和单个按钮 hover 态重叠太深 */
background: color-mix(in srgb, var(--bg-hover) 70%, transparent);
border-radius: var(--radius-sm);
box-shadow: inset 0 0 0 1px var(--border-subtle);
}
.btn-group .btn-icon {
width: 30px;
height: 30px;
border-radius: 5px;
}
.btn-group .btn-icon:hover:not(.is-active) {
background: var(--bg-hover);
}
.btn-group .btn-icon.is-active {
background: var(--bg-elevated);
color: var(--accent);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.12),
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* 图标 + 文字按钮(视图模式专用):
让核心交互(预览/编辑/双栏)一眼可识别,
同时保留 segmented control 的紧凑感。
12px 字号 + secondary(暗色 ~8.5:1)才能保证文字不糊;
2026-08 把 secondary 从 ~5.5:1 提到 ~8.5:1 后视图模式按钮显著更清晰 */
.btn-icon-text {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 30px;
padding: 0 10px;
border-radius: 5px;
color: var(--fg-secondary);
font-size: var(--fs-xs-readable);
font-weight: 500;
letter-spacing: var(--tracking-wide);
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
white-space: nowrap;
}
.btn-icon-text:hover:not(.is-active) {
background: var(--bg-hover);
}
.btn-icon-text:active {
transform: scale(0.96);
}
.btn-icon-text .btn-label {
line-height: 1;
/* 与按钮对齐基线 */
}
.btn-icon-text.is-active {
background: var(--bg-elevated);
/* 文字版(--accent-text):view mode "预览 / 编辑 / 双栏"、autosave "自动保存"
这种带字按钮 active 时若用纯 accent,"预览" "自动保存" 在工具栏里整片
accent 短词同时亮起来会让眼睛辛苦。降一档后 bg-elevated + inset 1px
accent 边框仍清楚表达"按下",文字不再贴脸 */
color: var(--accent-text);
font-weight: 600;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.12),
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* (audit fix Round 9: .toolbar-status-dot / .btn-icon-text > .toolbar-status-dot
死 CSS —— 保存按钮改 is-dirty 状态后该指示点彻底弃用,见
feedback-save-button-always-visible.md。iOS pill switch 替代品见下) */
/* iOS pill switch —— 替代 .toolbar-status-dot 作为开关语义。
26×14px 嵌入 .btn-icon-text,与现有 24px 按钮中线对齐。
视觉态由 [aria-pressed="true|false"] 选择器驱动,与项目
「aria-pressed 表示开关态」的模式一致(参考 btn-theme)。
保留 .btn-icon-text 外壳让按钮仍享有 hover/active 视觉反馈。 */
.toolbar-toggle .toggle-switch {
position: relative;
display: inline-block;
width: 26px;
height: 14px;
margin-left: 4px;
flex: 0 0 auto;
}
.toolbar-toggle .toggle-switch-track {
position: absolute;
inset: 0;
background: var(--fg-muted);
opacity: 0.5;
border-radius: 7px;
transition: background var(--transition-fast), opacity var(--transition-fast);
}
.toolbar-toggle .toggle-switch-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 12px;
height: 12px;
background: var(--bg-elevated);
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
transition: transform var(--transition-fast);
}
.toolbar-toggle[aria-pressed="true"] .toggle-switch-track {
background: var(--accent);
opacity: 1;
}
.toolbar-toggle[aria-pressed="true"] .toggle-switch-thumb {
transform: translateX(12px);
}
/* hover 微微提亮,与其它 btn-icon-text hover 一致 */
.toolbar-toggle:hover .toggle-switch-track { opacity: 0.85; }
.toolbar-toggle[aria-pressed="true"]:hover .toggle-switch-track { opacity: 1; }
/* 关态时整体暗化,与工具栏 fg-tertiary 一致,强调「关闭」语义 */
.toolbar-toggle[aria-pressed="false"] {
color: var(--fg-tertiary);
}
/* a11y:prefers-reduced-motion 下关闭 transform 过渡 */
@media (prefers-reduced-motion: reduce) {
.toolbar-toggle .toggle-switch-track,
.toolbar-toggle .toggle-switch-thumb { transition: none; }
}
.btn-icon.is-dirty,
.btn-icon-text.is-dirty {
/* 脏状态:使用 warning 色作为视觉提示,并轻微脉动 */
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
animation: dirty-pulse 1.8s ease-in-out infinite;
}
.btn-icon.is-dirty:hover,
.btn-icon-text.is-dirty:hover {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 18%, transparent);
}
/* 保存中:与 is-dirty 的 warning 区分,用 fg-secondary 中性色 + 微微透明度。
cursor: progress 给鼠标用户明确提示(参见 [[feedback-save-button-always-visible]]
——按钮始终可见,这里只是临时视觉变化,不切换 hidden)。 */
.btn-icon.is-saving,
.btn-icon-text.is-saving {
cursor: progress;
opacity: 0.6;
color: var(--fg-secondary);
background: var(--bg-hover);
/* 关掉 dirty-pulse 动画:避免 is-saving + is-dirty 叠加造成闪烁 */
animation: none;
}
/* a11y:prefers-reduced-motion 下保持稳定视觉,不透明度也保留 */
@media (prefers-reduced-motion: reduce) {
.btn-icon.is-saving,
.btn-icon-text.is-saving {
opacity: 0.7;
cursor: progress;
}
}
/* 主题按钮图标:按当前主题显示"代表当前状态"的那一态——
暗色主题下显示月亮,亮色主题下显示太阳(点击图标会切到对侧主题)。
两个 SVG 同时存在于 DOM,靠 [data-theme] 切换可见性,免 JS 维护。 */
#btn-theme .theme-icon {
display: none;
}
body[data-theme="dark"] #btn-theme .theme-icon-moon,
body[data-theme="light"] #btn-theme .theme-icon-sun {
display: block;
}
/* (audit fix Round 9: .toolbar-status-dot 死 CSS,详见 04-toolbar.css 顶部注释) */
/* dirty-pulse:脉动从 1 → 0.65 太深,warning 色按钮在 50% 帧会瞬间
看着像"warning 没了"。改成 1 → 0.78:仍能看出脉动,但始终保持 warning
视觉在场,"未保存"语义不中断。1.8s 节奏不变 */
@keyframes dirty-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.78; }
}
/* 工具栏图标按钮的 disabled 态(如非 md 文件下 preview/split 按钮)
复用 .context-menu-item 的禁用色(fg-tertiary + opacity),保持一致 */
.btn-icon:disabled,
.btn-icon[disabled] {
color: var(--fg-tertiary);
cursor: not-allowed;
opacity: 0.4;
pointer-events: none;
}
/* 图标+文字按钮(视图模式 预览/编辑/双栏)的 disabled 态。
非 Markdown 文件打开时 preview/split 按钮会被禁用 —— 这里让灰态视觉一致 */
.btn-icon-text:disabled,
.btn-icon-text[disabled] {
color: var(--fg-tertiary);
cursor: not-allowed;
opacity: 0.4;
pointer-events: none;
}
/* 窄窗口(< 820px):toolbar 文字标签隐藏,只保留图标。
与 sidebar 720px 阈值错开,留出中间过渡区,避免一次性把全部 toolbar 砍塌 */
@media (max-width: 820px) {
.btn-icon-text .btn-label {
display: none;
}
.btn-icon-text {
/* 没文字时收紧 padding,让按钮保持图标按钮的紧凑感 */
padding: 0 6px;
}
}
/* ============================================================
App Shell
============================================================ */
.app-shell {
display: grid;
/* 侧栏宽度走 --sidebar-width(运行时可拖拽调整),默认值来自 --w-sidebar;
这样改 token 不必同时改 grid 模板,也避免与窄屏媒体查询耦合 */
grid-template-columns: var(--sidebar-width) 4px 1fr;
min-height: 0;
overflow: hidden;
}
/* 侧栏拖拽分割条:只在 split / preview / edit 单栏模式下显示。
视觉宽度 4px。命中区扩到 ±6px(共 12px):由 app.js 的 mountSidebarSplitter
监听 document mousedown + mousemove,在鼠标距 splitter 中线 ≤6px 时启动拖动,
并把光标改成 col-resize 提示可拖。这是 grid track 4px 限制下的标准做法。 */
.sidebar-splitter {
background: var(--border-subtle);
cursor: col-resize;
position: relative;
width: 4px;
transition: background var(--transition-fast);
}
.sidebar-splitter:hover,
.sidebar-splitter.is-dragging {
background: var(--accent-soft);
}
.sidebar-splitter::after {
/* 抓握提示:两条短竖线 */
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 24px;
background: var(--border-default);
box-shadow: 3px 0 0 var(--border-default);
}
.sidebar-splitter:hover::after,
.sidebar-splitter.is-dragging::after {
background: var(--accent);
box-shadow: 3px 0 0 var(--accent);
}
/* 侧栏恢复默认宽度按钮(hover splitter 时显形)
- 4px splitter 视觉宽度里塞不下按钮,所以按钮是 absolute 定位居中 + 视觉溢出;
但 splitter 本身 hover 区域扩到 ±6px(HIT_RADIUS),按钮顺势接管这块区域
- 默认 opacity:0 + pointer-events:none → 完全不挡 drag 命中区;
hover splitter / 按钮自身 / focus-visible 时显形 */
.sidebar-splitter-reset {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 18px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 3px;
color: var(--fg-secondary);
cursor: pointer;
padding: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.sidebar-splitter:hover .sidebar-splitter-reset,
.sidebar-splitter-reset:focus-visible {
opacity: 1;
pointer-events: auto;
}
.sidebar-splitter-reset:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.sidebar-splitter-reset:active {
background: var(--accent);
color: var(--bg-card);
}

872
src/styles/05-sidebar.css Normal file
View File

@@ -0,0 +1,872 @@
/* ===========================================================================
Sidebar + Viewer + Editor Split Layout
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ============================================================
Sidebar
============================================================ */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
min-height: 0;
z-index: var(--z-sidebar);
/* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */
min-width: 0;
}
.sidebar-header {
/* 紧凑的 header —— 上下 12px、左右 14px,让搜索框有呼吸但不浪费空间 */
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: var(--space-2);
}
/* 新建笔记行:放在 .sidebar-header 下方、排序条上方的独立主操作行。
左右 padding 用 10px(与 .sidebar-sort / .file-list 对齐),
让「创建」按钮和它作用的列表视觉上是一组;搜索框保留 14px 更深的内嵌来强调 */
.sidebar-new-row {
padding: 10px 10px 8px;
}
/* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮
——accent 文字 + 微微的 accent 底色 + 1px accent 边框,hover/active 进一步加深。
2026-08 把字号从 12px 提到 14px、字重 500 → 600、加 8% accent 底色 + 32% accent 边框,
解决「新建笔记」字看着发淡、按钮太瘦的问题 */
.sidebar-new-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 34px;
padding: 0 12px;
border-radius: var(--radius-sm);
/* audit fix (Round 9):font-size 13px (fs-sm) 的文字走 --accent-text(混色 65/70%),
不能用装饰色 token --accent(饱和度太高,13px 文字看着刺眼)。
见 [[feedback-colored-text-tier]]。background / border 仍走 --accent 因为
它们是装饰而非文字。 */
color: var(--accent-text);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
font-size: var(--fs-sm);
font-weight: 600;
letter-spacing: 0;
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.sidebar-new-btn:hover {
/* audit fix (CSS P2 / sidebar new-btn hover):之前 color 走 --accent-hover
(纯 accent 饱和色),13px 小字直接用饱和色会"扎眼"、在 dark 调色板
还会出现 vibration(红绿高频抖动)现象。base 态已经走 --accent-text
(accent 65/70% 混色),hover 应当保持同套语义色 token 一致,只是
把混色比例上调一档或换更暖的 --accent-text 变体,让视觉权重随交互
上升但 chroma 仍温和(见 [[feedback-colored-text-tier]])。 */
color: var(--accent-text);
border-color: var(--accent);
background: var(--accent-soft-hover);
}
.sidebar-new-btn:active {
transform: scale(0.98);
}
.sidebar-new-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.search-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
/* 用 secondary 替代 tertiary:搜索框是核心入口,图标应该一眼看清 */
color: var(--fg-secondary);
pointer-events: none;
transition: color var(--transition-fast);
}
.search-input {
width: 100%;
/* 收紧内边距、改为 8px/0/8px/32px;高度由 line-height + 7px*2 padding 决定 ≈ 30px */
padding: 7px 10px 7px 32px;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
color: var(--fg-primary);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.search-input::placeholder {
/* 占位符原本用 fg-muted(暗色 ~2:1 几乎看不见),提到 fg-secondary 让"搜索文件..."
提示文字可见 —— 用户至少能看清这里有输入框 */
color: var(--fg-tertiary);
}
.search-input:hover {
border-color: var(--border-default);
}
.search-input:focus {
/* 鼠标点击不画外圈:键盘 focus-visible 由全局规则接管(见 :focus-visible 基线) */
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-glow);
background: var(--bg-elevated);
}
@supports (focus-visible) {
.search-input:focus:not(:focus-visible) {
box-shadow: none;
}
.search-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
}
.search-input:focus + .search-icon {
color: var(--accent);
}
/* ============================================================
Sidebar 排序工具条
紧贴它作用的 .file-list 上方 —— 这是「列表的工具」,
不应该埋进三层深的设置对话框(见 [[feedback-prefer-near-affected-area]])。
设计目标:紧凑到不抢搜索框注意力,active 态明确但不过重。
============================================================ */
.sidebar-sort {
display: flex;
gap: 2px;
/* 顶 header 12px、底 6px —— 让工具条与列表的视觉关联 > 与 header 的关联 */
padding: 6px 10px 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-sort-btn {
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
/* 2026-08 把字号从 12px 提到 13px(--fs-sm),与文件 meta / 工具栏按钮文字同档,
解决排序按钮在 13px 字号下字看着小、发淡的问题。字重 500 → 600 让文字更"实" */
color: var(--fg-secondary);
font-size: var(--fs-sm);
font-weight: 600;
letter-spacing: var(--tracking-wide);
cursor: pointer;
/* 防止「修改时间」等 4 字标签在 180px 窄侧栏里换行成两行(高度塌到 46px,
挤压下方文件列表)。即使不够也用省略号截断,保持一行紧凑 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
background var(--transition-fast),
color var(--transition-fast),
border-color var(--transition-fast);
}
.sidebar-sort-btn:hover {
color: var(--fg-secondary);
background: var(--bg-hover);
}
.sidebar-sort-btn.is-active {
/* active 选中态:文字降彩度(--accent-text)。"名称"/"修改时间"/"创建时间"
三个 sort 按钮并排时,纯 accent 文字让三者同时"跳出来",观感刺眼;
改用文字版后,accent-soft 底色仍明确选中,但字不再"贴脸"。 */
color: var(--accent-text);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
}
.sidebar-sort-btn.is-active:hover {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
}
.sidebar-sort-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.sidebar-sort-btn svg {
flex: 0 0 auto;
}
.file-list {
flex: 1;
overflow-y: auto;
/* 上下 8px 比之前的 12px 更紧凑;列表项自身有 padding 已经够了 */
padding: 8px 10px;
/* 列表项间距:用 gap 而非 item 的 margin-bottom,避免 list 总高累加偏差。
1px gap 与原 margin-bottom:1px 视觉一致,但语义更清晰 */
display: flex;
flex-direction: column;
gap: 1px;
/* 滚动条预留空间 —— 让滚动条 hover 才出现,不抢视觉 */
scrollbar-gutter: stable;
}
.file-list:empty::after {
/* 空列表:淡灰色提示。设置里改数据目录、或把 .md 拖到目录里都会自动消失。
原来用 --fg-muted 在 bg-sidebar 上仅 ~3.2:1(default dark),低于 WCAG AA。
提到 --fg-tertiary(~5.4:1)让这段引导文字真能"看清"——
无列表可看时用户全部注意力都落在这段字上,发淡等于无效提示 */
content: attr(data-empty);
display: block;
padding: var(--space-7) var(--space-3);
text-align: center;
color: var(--fg-tertiary);
font-size: var(--fs-sm);
line-height: var(--lh-relaxed);
/* 留点上方间距,与 sidebar-header 拉开 */
margin-top: var(--space-2);
/* 避免一行字过长在窄侧栏里截断为半个字 */
word-break: break-word;
}
.file-item {
/* grid 两列:16px 图标 + 1fr 文本区。
用 grid 而不是 padding-left+absolute 是为了:
1) 文本区能正常占满剩余宽度(min-width:0 走 grid 才生效)
2) 图标列固定,文本列弹性,缩窄侧栏不挤图标 */
display: grid;
grid-template-columns: 16px 1fr;
align-items: center;
column-gap: 10px;
/* 上下 padding 9px → 6px:原 9px 在 13px name + 13px meta 两行下让单条 file-item
撑到 ~60px,50 个文件就要 3000px 滚动,太松。压到 6px 后 ~50px/item,
列表更紧凑、可见项更多,仍保留点击区域与 hover 反馈呼吸 */
padding: 6px 12px 6px 10px;
/* margin-bottom 去掉,改用 file-list 的 gap 统一间距;
同时消除"两行间距靠 margin 累加"导致的列表总高计算偏差 */
border-radius: var(--radius-sm);
cursor: pointer;
/* transition 增加 border-color / box-shadow,让激活态色条 + 微弱内阴影的进入更顺滑 */
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
position: relative;
}
.file-text {
/* 文本列:name + meta 仍然竖排;min-width:0 让 ellipsis 真正生效 */
display: flex;
flex-direction: column;
/* name↔meta gap 2px → 1px:与 file-item 上下 padding 同步收紧,让整条紧凑 */
gap: 1px;
min-width: 0;
}
.file-icon {
/* 平时 muted,hover/active 时跟随主题升级。
用 grid 让图标"漂"在 item 左侧 + 上下居中,比 padding-left+absolute 更稳 */
color: var(--fg-tertiary);
flex-shrink: 0;
transition: color var(--transition-fast);
}
.file-item:hover .file-icon {
color: var(--fg-secondary);
}
.file-item:active {
transform: scale(0.985);
}
.file-item.is-active {
/* 激活态:accent-soft 底色 + 左侧 3px 色条。
用 accent-soft 替代 accent-soft-hover:hover 态是临时高亮(更深一档),
active 态是稳定选中,两者用不同 token 区分意图 */
background: var(--accent-soft);
}
.file-item.is-active:hover {
/* 激活态 hover 走更深一档 —— 与 idle 态 hover(bg-hover)形成层次差 */
background: var(--accent-soft-hover);
}
.file-item.is-active .file-icon {
/* 激活态:图标与正文同色,构成一致的视觉语言 */
color: var(--accent);
}
.file-item.is-active::before {
content: "";
position: absolute;
/* 从 -3px 移到 0,让色条贴着 item 内沿,更紧凑不"漂" */
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
border-radius: 0 2px 2px 0;
background: var(--accent);
}
.file-name {
font-size: var(--fs-sm);
color: var(--fg-primary);
/* 字重 500 → 600:让 file-name 成为主视觉。
之前 file-meta 也是 600 + 同字号,导致 meta 看起来比 name 还"实",
层级倒挂(次要信息喧宾夺主)。现在 name 600 / meta 500,名字优先 */
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.file-item .file-name mark {
/* 搜索高亮:在文件名里命中搜索关键字时的视觉标识。
22% accent 背景已经给出"高亮"信号,文字再纯 accent 会与背景同色相"贴
一起"。改用 --accent-text —— 仍可识别色相(青蓝/紫/红等色调随 palette),
但 chroma 降到不会与底色"撞色"的程度。
字重保持 700,因为 mark 通常只有 1-3 字,需要靠粗细扛"高亮"识别。 */
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--accent-text);
padding: 0 2px;
border-radius: 3px;
font-weight: 700;
}
.file-item.is-active .file-name {
/* accent #5e6ad2 在 #08090a 上只有 ~4.0:1,作为正文偏暗。
用与正文链接同色相的 --md-link(已在 #08090a 上 ~5.5:1)替代 */
color: var(--md-link);
/* 已与 idle file-name 同字重(600),保持选中态只是颜色升级、不再额外加粗 */
font-weight: 600;
}
.file-meta {
/* 字号 13px → 12px(--fs-xs-readable),比 file-name 小一档;
字重 600 → 500 让 meta 视觉退到次级,与 file-name 的 600 形成对比。
两层降级(更小 + 更轻)让 meta 真正成为"辅助信息"而不是与 name 平级 */
font-size: var(--fs-xs-readable);
color: var(--fg-secondary);
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-wide);
/* 窄侧栏(拖到 180px)时 meta 行可能换行成两行,让 file-item 高度跳变。
强制单行 + 省略号,保持列表项高度稳定 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item.is-active .file-meta {
color: var(--md-link);
opacity: 0.75;
}
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 2px;
}
/* 侧栏底部视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开。
用 1px 细线 + 上下 6px 留白,避免紧贴导致 AI 按钮和文件夹链接"挤成一团" */
.sidebar-footer-sep {
height: 1px;
background: var(--border-subtle);
margin: 6px 0;
}
/* AI 主操作按钮在侧栏底部已不再与 footer-sep 紧邻(sep 在 AI 上方),
不再需要 margin-bottom: 0 来解耦 gap。 */
.link-subtle {
display: flex;
align-items: center;
gap: var(--space-2);
/* 11px 字号在低 DPI 上偏糊,提到 12px(fs-xs-readable)保持视觉小但不模糊;
颜色已用 secondary(2026-08 提亮后约 8.5:1 on dark) */
font-size: var(--fs-xs-readable);
color: var(--fg-secondary);
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
font-weight: 500;
min-width: 0; /* 允许 flex item 在窄侧栏(180px)收缩到内容宽度以下 */
}
/* 文字单行 + 溢出省略号:防止「切换数据文件夹…」在窄侧栏里折成两行 */
.link-subtle > span {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* link-subtle / footer-action:侧栏底部"打开数据目录 / 切换目录"等
二级链接。hover 时给 bg-hover + accent 文字提示"可点"——
与全局 a 一致用 --accent-text,让"hover 才出颜色"的反馈也维持文字版。 */
.link-subtle:hover {
background: var(--bg-hover);
color: var(--accent-text);
}
/* <button> 重置:与 .link-subtle 一致的"链接外观",但语义上是动作按钮 */
.footer-action {
width: 100%;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
font: inherit;
}
/* 键盘 focus 高亮 —— 鼠标点击不画,键盘 Tab 才出现(hover 不等于 focus) */
.link-subtle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================
Viewer
============================================================ */
.viewer {
background: var(--bg-viewer);
overflow-y: auto;
min-width: 0;
display: flex;
flex-direction: column;
scroll-behavior: smooth;
}
.markdown-body {
flex: 1;
max-width: var(--w-reader-max);
width: 100%;
margin: 0 auto;
padding: var(--space-9) var(--space-7) calc(var(--space-9) * 1.5);
font-family: var(--font-reading);
font-size: var(--reader-font-size);
line-height: var(--reader-line-height);
color: var(--fg-primary);
user-select: text;
letter-spacing: 0.005em;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 空状态:更有温度的视觉 */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* gap 用 0,靠各元素的 margin 控制间距:
- icon → 标题:靠 icon 自身的 margin-bottom
- 标题 → 主 hint:12px,让主信息与提示分隔
- 主 hint → 次 hint:6px,形成层次
用统一 gap 会让主 hint 与次 hint 之间也有 12px,反而拉得太平 */
gap: 0;
color: var(--fg-tertiary);
text-align: center;
padding: var(--space-7);
/* 顶部加一点点品牌色辉光,强化「这是 Notes 应用」的氛围 */
position: relative;
}
.empty-state-icon {
width: 88px;
height: 88px;
border-radius: 50%;
/* 三层渐变叠出「呼吸光晕」:从中心发散到外圈的柔光 */
background:
radial-gradient(circle at 50% 45%,
color-mix(in srgb, var(--accent) 22%, transparent) 0%,
color-mix(in srgb, var(--accent) 8%, transparent) 35%,
transparent 70%);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
margin-bottom: var(--space-3);
position: relative;
/* 让 halo 缓慢呼吸,强化「等待/邀请」感 */
animation: empty-halo 4s ease-in-out infinite;
}
.empty-state-icon::after {
/* 内圈实色圆 + 微阴影:让图标有「浮起」感 */
content: "";
position: absolute;
inset: 16px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 12%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent),
0 4px 12px color-mix(in srgb, var(--accent) 8%, transparent);
z-index: -1;
}
.empty-state-icon svg {
/* 图标本身略浮起,配合 halo 制造层次 */
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--accent) 18%, transparent));
}
@keyframes empty-halo {
0%, 100% {
transform: scale(1);
opacity: 0.95;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
.empty-state p {
margin: 0;
font-size: var(--fs-lg);
color: var(--fg-primary);
font-weight: 700;
/* 标题与下面 hint 之间的留白由 gap 控制,避免再加 margin */
letter-spacing: var(--tracking-tight);
/* 标题用次级 fg-primary 突出 —— primary 比 secondary 更有视觉重量 */
}
/* 标题("选择一个 Markdown 文件开始阅读")需要与图标 / 提示都拉出层次 */
.empty-state > p:not(.hint) {
margin-bottom: var(--space-3);
}
.empty-state .hint {
font-size: var(--fs-sm);
/* 13px 提示文本:用 secondary(2026-08 提亮到 ~8.5:1),避开 tertiary 旧值的 3.5:1
—— 注释说要用 secondary 但实际代码仍是 tertiary,已修复 */
color: var(--fg-secondary);
/* max-width 从 360 提到 400:让"放进数据文件夹后," + ".md" chip 一行内放得下,
避免折成两行导致后段「自动出现」只剩一两个汉字成行(orphan 字)。
text-wrap: pretty 是 Chromium 114+ 的 CSS Text Level 4 优化,
优先避免单字成行(对 CJK 也生效:算法看 line-end penalty) */
max-width: 400px;
line-height: var(--lh-relaxed);
/* 主 hint 与标题之间已经由标题 margin-bottom 12px 撑开,
主 hint 自身的 margin-top 设为 0 避免与上面 margin 叠加 */
margin-top: 0;
text-wrap: pretty;
}
/* 二级提示:用 tertiary + 略小字号,与主 hint 拉开视觉权重 */
.empty-state .hint.hint-secondary {
color: var(--fg-tertiary);
font-size: var(--fs-xs-readable);
margin-top: 6px;
max-width: 380px;
}
.empty-state .hint kbd {
/* .md 这种代码 chip 用 kbd 标签更语义化 */
padding: 2px 7px;
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
/* 2026-08 从 11px 提到 12px:11px 在低 DPI 上偏糊,挤在提示文本里看着小 */
font-size: var(--fs-xs-readable);
font-family: var(--font-mono);
color: var(--fg-secondary);
font-weight: 600;
box-shadow: 0 1px 0 var(--border-subtle);
}
.empty-state code {
padding: 2px 8px;
background: var(--bg-hover);
border-radius: var(--radius-sm);
/* 0.85em of 13px ≈ 11px —— 提到 12px(--fs-xs-readable)让 .md chip 跟 kbd 同档 */
font-size: var(--fs-xs-readable);
font-family: var(--font-mono);
/* accent 文字在 bg-hover 上仅 ~4:1;改用 fg-secondary 满足 AA */
color: var(--fg-secondary);
}
/* ============================================================
Editor Pane & Split View
三种模式:preview(仅 viewer)/ edit(仅 editor)/ split(双栏 + 分割条)
============================================================ */
body[data-editor-mode="split"] .app-shell {
/* 五列:sidebar | sb-splitter | editor | splitter | viewer
editor 用 px (--w-editor) 由 app.js 拖动时写在 .app-shell 的 style 上;
viewer 用 1fr 自动填剩余空间。
这样在 split + AI 模式下拖 ai-splitter 不会挤编辑器(详见 src/app.js mountSplitter)。
minmax(280px, ...) 给编辑器最小宽度 —— 编辑器内容至少要够阅读宽度才不挤;
默认 var(--w-editor, 50%) 在 CSS fallback 阶段给一个等分初始值,
app.js 启动时根据 splitRatio 重算覆盖。 */
grid-template-columns:
var(--sidebar-width)
4px
minmax(280px, var(--w-editor, 50%))
4px
minmax(0, 1fr);
}
body[data-editor-mode="preview"] .app-shell {
/* 三列:sidebar | sb-splitter | viewer */
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
body[data-editor-mode="edit"] .app-shell {
/* 三列:sidebar | sb-splitter | editor */
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
.editor-pane {
background: var(--bg-viewer);
overflow: hidden; /* CM6 自己处理滚动 */
min-width: 0;
min-height: 0;
display: none; /* 默认隐藏(preview 模式) */
user-select: text;
}
body[data-editor-mode="edit"] .editor-pane,
body[data-editor-mode="split"] .editor-pane {
display: flex;
flex-direction: column;
}
/* 让编辑器像 viewer 一样「居中 + 限定行宽」,与预览区域视觉重量一致。
max-width 与 .markdown-body 共用 --w-reader-max,保证 gutter + 内容总宽
不超过 reader max;margin auto 在更宽的窗口中把编辑器推到中部。
窄窗口下 max-width 不再生效,编辑器自然占满全宽(不破坏小屏体验)。 */
.editor-pane .cm-editor {
max-width: var(--w-reader-max);
margin: 0 auto;
width: 100%;
}
/* 窄窗口(< 720px):三栏挤到 1xx px 时退化为「单栏 + 隐藏阅读视图」——
至少保留可读可编辑区域。minWidth: 600 仍由主进程限制,这里只处理 600~720 区间 */
@media (max-width: 720px) {
body[data-editor-mode="split"] .app-shell {
/* 侧栏 splitter 仍保留,方便用户把侧栏压到最小留更多编辑区 */
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
/* 直接隐藏 editor/viewer 之间的 splitter 元素,避免它作为孤儿占据第 5 个 grid 单元 */
body[data-editor-mode="split"] .splitter {
display: none !important;
}
body[data-editor-mode="split"] .viewer {
display: none !important;
}
/* 窄屏默认侧栏宽度变窄 */
:root { --sidebar-width: var(--w-sidebar-narrow); }
}
/* edit 模式:隐藏阅读视图 —— 编辑器已经占据右侧,不需要再显示预览 */
body[data-editor-mode="edit"] .viewer {
display: none;
}
.splitter {
background: var(--border-subtle);
cursor: col-resize;
position: relative;
display: none;
transition: background var(--transition-fast);
}
body[data-editor-mode="split"] .splitter {
display: block;
}
.splitter:hover,
.splitter.is-dragging {
background: var(--accent-soft);
}
.splitter::after {
/* 抓握提示:两条短竖线 */
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 24px;
background: var(--border-default);
box-shadow: 3px 0 0 var(--border-default);
}
.splitter:hover::after,
.splitter.is-dragging::after {
background: var(--accent);
box-shadow: 3px 0 0 var(--accent);
}
/* 编辑器底色 / 文字色已由 editor-theme.js 的 spec(var(--bg-viewer) / var(--fg-primary))
覆盖;老的"dark 强制 bg-viewer、light 强制 bg-viewer"那段代码冗余且容易出错,删除。 */
.cm-editor .cm-tooltip {
background: var(--bg-elevated);
color: var(--fg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
}
.cm-editor .cm-tooltip-autocomplete ul li[aria-selected] {
background: var(--accent-soft);
color: var(--accent);
}
/* ============================================================
Folder Browser(Stage 8):面包屑 + 文件夹 / 二进制条目样式
面包屑放在 .sidebar-sort 下方、.file-list 上方,贴近它作用的列表。
根目录时整个 <nav> hidden(HTML 里默认带 hidden 属性),
不需要 CSS 处理空态。
文件夹(.file-item.is-folder):
- 文件夹图标 + 名称略加粗(区别于普通文件)
- meta 行简化为「文件夹」
二进制(.file-item.is-binary):
- opacity 0.45 + cursor not-allowed —— 视觉与文件管理器对不可执行项的灰态一致
- 仍可被点击(事件不被禁用,渲染端弹 toast 提示)
============================================================ */
.sidebar-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px 4px;
/* 横向溢出 + 不换行:用户进入很深层子目录时(如 archive/2026/03/15)
末尾「当前段」是最重要的,前面的历史段可以横滑出可见区 */
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
/* 让滚动条只在 hover 时更显眼,平时几乎不可见 —— 避免视觉噪音 */
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.sidebar-breadcrumb:hover {
scrollbar-color: var(--border-subtle) transparent;
}
/* 回到根按钮:图标 + 文字,单独占左侧 */
.sidebar-breadcrumb-root {
display: inline-flex;
align-items: center;
gap: 3px;
flex: 0 0 auto;
padding: 2px 6px;
border: none;
background: transparent;
color: var(--fg-secondary);
font-size: var(--fs-xs);
cursor: pointer;
border-radius: 3px;
font-family: inherit;
}
.sidebar-breadcrumb-root:hover {
background: var(--bg-hover);
color: var(--fg-primary);
}
.sidebar-breadcrumb-root:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.sidebar-breadcrumb-root svg {
flex: 0 0 auto;
}
/* 面包屑分段按钮(中间段,可点击跳转) */
.sidebar-breadcrumb-btn {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 2px 6px;
border: none;
background: transparent;
color: var(--fg-secondary);
font-size: var(--fs-xs);
cursor: pointer;
border-radius: 3px;
font-family: inherit;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-breadcrumb-btn:hover {
background: var(--bg-hover);
color: var(--accent);
}
.sidebar-breadcrumb-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* 当前段(非可点击,纯文本):稍亮,与「根 / 中间段」做对比 */
.sidebar-breadcrumb-current {
flex: 0 0 auto;
padding: 2px 6px;
font-size: var(--fs-xs);
color: var(--fg-primary);
font-weight: 600;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-breadcrumb-sep {
flex: 0 0 auto;
color: var(--fg-muted);
font-size: var(--fs-xs);
opacity: 0.6;
}
/* 文件夹条目:粗体名称 + 文件夹图标颜色稍亮 */
.file-item.is-folder .file-name {
font-weight: 600;
color: var(--fg-primary);
}
.file-item.is-folder .file-icon {
/* 文件夹图标用 accent 而不是 fg-muted:让「这是目录」的视觉信号更直接 */
color: var(--accent);
}
/* 二进制条目:灰态 + cursor 提示不可编辑,但仍可点击(弹 toast) */
.file-item.is-binary {
opacity: 0.45;
cursor: not-allowed;
}
.file-item.is-binary .file-icon {
/* 与灰度视觉统一:图标也走 fg-muted */
color: var(--fg-muted);
}
.file-item.is-binary:hover {
/* 灰态项 hover 不要让背景喧宾夺主 —— 用更弱的 hover 背景 */
background: var(--bg-hover);
}
.file-item.is-binary.is-active {
/* 即使被键盘选中也不打破「不可编辑」的视觉信号 —— 不用 accent 高亮 */
background: var(--bg-hover);
}
.file-item.is-binary.is-active::before {
/* 不画左侧色条:用户已经看 opacity 知道这一项不可编辑 */
opacity: 0.4;
}

295
src/styles/06-markdown.css Normal file
View File

@@ -0,0 +1,295 @@
/* ===========================================================================
Markdown Body — 排版优先的 GFM 样式
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ============================================================
Markdown Body — 排版优先的 GFM 样式
============================================================ */
/* 段落间距:呼吸感 */
.markdown-body > * + * {
margin-top: 1.2em;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-weight: 700;
line-height: var(--lh-snug);
color: var(--md-heading);
letter-spacing: var(--tracking-tight);
scroll-margin-top: var(--space-5);
font-family: var(--font-sans);
}
.markdown-body h1 {
font-size: var(--fs-4xl);
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.6em;
padding-bottom: 0.4em;
border-bottom: 1px solid var(--md-hr);
}
.markdown-body h2 {
font-size: var(--fs-2xl);
font-weight: 700;
margin-top: 2.2em;
margin-bottom: 0.6em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--md-hr);
}
.markdown-body h3 {
font-size: var(--fs-xl);
margin-top: 1.8em;
margin-bottom: 0.5em;
}
.markdown-body h4 {
font-size: var(--fs-lg);
margin-top: 1.6em;
margin-bottom: 0.5em;
color: var(--fg-secondary);
}
.markdown-body h5,
.markdown-body h6 {
font-size: var(--fs-base);
margin-top: 1.4em;
margin-bottom: 0.5em;
color: var(--fg-tertiary);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
/* 标题后的第一个元素减少间距 */
.markdown-body h1 + *,
.markdown-body h2 + *,
.markdown-body h3 + *,
.markdown-body h4 + * {
margin-top: 0;
}
.markdown-body p {
margin: 0;
text-align: justify;
hyphens: auto;
}
.markdown-body a {
color: var(--md-link);
text-decoration: none;
border-bottom: 1px solid var(--md-link);
padding-bottom: 1px;
font-weight: 500;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.markdown-body a:hover {
color: var(--md-link-hover);
border-bottom-color: var(--md-link-hover);
}
.markdown-body strong {
font-weight: 700;
color: var(--fg-primary);
}
.markdown-body em {
font-style: italic;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 1.6em;
line-height: var(--lh-loose);
}
.markdown-body ul {
list-style: none;
}
.markdown-body ul > li {
position: relative;
}
.markdown-body ul > li::before {
content: "";
position: absolute;
left: -1em;
top: 0.7em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
opacity: 0.7;
}
.markdown-body ol {
list-style: decimal;
font-variant-numeric: tabular-nums;
}
.markdown-body ol > li::marker {
color: var(--accent);
font-weight: 600;
}
.markdown-body li {
margin-bottom: 0.4em;
}
.markdown-body li + li {
margin-top: 0;
}
.markdown-body li > ul,
.markdown-body li > ol {
margin-top: 0.4em;
}
.markdown-body input[type="checkbox"] {
margin-right: var(--space-2);
accent-color: var(--accent);
}
/* 引用:左色边 + 浅背景 + 斜体 */
.markdown-body blockquote {
padding: var(--space-3) var(--space-5);
border-left: 3px solid var(--md-blockquote-border);
background: var(--md-blockquote-bg);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--fg-secondary);
font-style: italic;
margin: 1.5em 0;
}
.markdown-body blockquote p {
margin: 0;
}
.markdown-body blockquote p + p {
margin-top: 0.6em;
}
/* 行内代码:淡淡的着色背景,不破坏阅读节奏 */
.markdown-body code {
padding: 2px 7px;
background: var(--md-inline-code-bg);
color: var(--md-code-text);
border-radius: 4px;
font-size: 0.88em;
font-weight: 500;
}
/* 代码块:等宽字体 + 暗背景 + 行号占位 */
.markdown-body pre {
padding: var(--space-4) var(--space-5);
background: var(--md-code-bg);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: var(--fs-sm);
line-height: 1.7;
margin: 1.4em 0;
border: 1px solid var(--border-subtle);
position: relative;
}
.markdown-body pre code {
padding: 0;
background: none;
color: inherit;
font-size: inherit;
white-space: pre;
font-weight: 400;
}
/* 水平分割线 */
.markdown-body hr {
border: none;
height: 1px;
background: var(--md-hr);
margin: 2.5em 0;
position: relative;
}
.markdown-body hr::after {
content: "· · ·";
position: absolute;
left: 50%;
top: -0.7em;
transform: translateX(-50%);
background: var(--bg-viewer);
color: var(--fg-muted);
padding: 0 1em;
font-size: var(--fs-base);
letter-spacing: 0.4em;
}
/* 表格:优雅的边框 + 表头背景 */
.markdown-body table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--fs-base);
margin: 1.5em 0;
border: 1px solid var(--md-table-border);
border-radius: var(--radius-md);
overflow: hidden;
font-family: var(--font-sans);
}
.markdown-body th,
.markdown-body td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--md-table-border);
text-align: left;
line-height: var(--lh-snug);
}
.markdown-body th {
background: var(--md-table-header-bg);
font-weight: 600;
color: var(--fg-primary);
letter-spacing: var(--tracking-tight);
border-bottom-width: 2px;
}
.markdown-body tr:last-child td {
border-bottom: none;
}
.markdown-body tbody tr:nth-child(even) td {
background: var(--md-table-stripe);
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin: 1em 0;
}
.markdown-body del {
color: var(--fg-tertiary);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* 键盘按键样式 */
.markdown-body kbd {
padding: 2px 6px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 4px;
font-size: 0.85em;
box-shadow: 0 1px 0 var(--border-default);
}

446
src/styles/07-statusbar.css Normal file
View File

@@ -0,0 +1,446 @@
/* ===========================================================================
Statusbar + Toast
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ============================================================
Statusbar
============================================================ */
.statusbar {
display: flex;
align-items: center;
height: var(--h-statusbar);
padding: 0 14px;
background: var(--bg-toolbar);
border-top: 1px solid var(--border-subtle);
/* 状态栏显示路径 / 字数 / 模式 / 文件数,是核心信息展示区,
颜色用 primary(最重),font-weight: 500 保证不淡。
旧值 secondary 在 13px 下仍偏淡,看着发灰 */
font-size: var(--fs-sm);
font-weight: 500;
color: var(--fg-primary);
gap: var(--space-3);
z-index: var(--z-statusbar);
letter-spacing: var(--tracking-wide);
/* audit fix (P2):与 toolbar 同步去掉 backdrop-filter —— 实色背景上看不出
毛玻璃效果,但每帧重算模糊成本真实存在(1200×24 px × 60 fps = 大量 GPU)。 */
}
/* 三段式状态栏:左段(弱化路径)/ 中段(核心字数)/ 右段(chip 簇) */
.status-segment {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.status-segment-left {
flex: 0 1 auto;
min-width: 0;
/* 路径长时允许截断;用 ellipsis 而不是 overflow 隐藏 */
}
.status-segment-center {
flex: 0 0 auto;
justify-content: center;
}
.status-segment-right {
/* 用 0 1 auto 而不是 0 0 auto:让右段在窗口窄时也能压缩,
不至于把整段 statusbar 顶出去。count chip 收敛后整体宽度更小,
给一个"柔性右段"避免溢出 */
flex: 0 1 auto;
justify-content: flex-end;
}
/* 右段:scroll / mode / AI 是核心可交互 chip,count 是辅助信息。
chip 组与 count 之间用细分隔线隔开,形成"主-辅"分组。
比单纯加大 gap 更精致,与左段 [path | divider | meta] 同款语法 */
.status-segment-right .status-divider {
margin: 0 var(--space-1);
}
.status-path {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
font-family: var(--font-mono);
font-size: var(--fs-sm);
/* 路径是状态栏最重要的信息(当前打开的文件位置),用 primary 不发淡。
旧值 secondary 在 13px mono 看着发灰 */
color: var(--fg-primary);
font-weight: 500;
}
/* 路径 chip 可点击态:hover 时背景与 accent 文本提示「可点」。
通过 button 元素实现 —— 与 status-chip-clickable 风格统一。 */
.status-path-clickable {
border: none;
background: transparent;
cursor: pointer;
padding: 5px 8px;
margin: 0 -8px;
/* button 默认字体继承 */
font: inherit;
font-family: var(--font-mono);
font-size: var(--fs-sm);
font-weight: 500;
color: var(--fg-primary);
border-radius: var(--radius-sm);
transition: background var(--transition-fast), color var(--transition-fast);
}
/* 路径 hover:与全局 a 同步用 --accent-text,配合 bg-hover 给"可点"反馈。
不要纯 --accent:状态栏里长时间停留的路径若 hover 出满彩度文字,眼睛
会被刚指上去那一下"拉住",反而显得突兀。 */
.status-path-clickable:hover {
background: var(--bg-hover);
color: var(--accent-text);
}
.status-path-clickable:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.status-divider {
flex: 0 0 auto;
width: 1px;
height: 10px;
/* 圆角胶囊状的点状分隔符,比纯色线条更精致 */
background: var(--border-default);
border-radius: 1px;
}
.status-meta {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
font-size: var(--fs-sm);
font-weight: 500;
}
.status-spacer {
flex: 1 1 auto;
}
.status-count {
flex: 0 0 auto;
color: var(--fg-tertiary);
font-variant-numeric: tabular-nums;
font-weight: 500;
/* 文件计数是辅助信息("5 个文件"),不再用 bg-hover chip:
让右段核心信息(模式 / AI / 滚动)更突出,count 退到 tertiary 文字。
用户需要时 hover 也能感知,但视觉权重降低 */
padding: 5px 4px;
}
/* 状态栏 chip:模式 / 字数 / 滚动进度 */
.status-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 5px 8px;
border-radius: var(--radius-sm);
background: var(--bg-hover);
color: var(--fg-primary);
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: var(--fs-sm);
letter-spacing: var(--tracking-wide);
/* 状态切换平滑:saved ↔ unsaved、preview ↔ edit ↔ split 等通过 class / data-mode
切换会发生 background + color 跳变。无 transition 时眼睛会"被光闪一下"——
这正是用户说的"看着不舒服"。120ms ease-out 跟上主节奏,跨态变化
自然衔接。prefers-reduced-motion 不关 transition(10-a11y.css 注释),
前庭敏感用户仍得到"可感知反馈"但不至于动画扰神 */
transition:
background var(--transition-fast),
color var(--transition-fast);
}
/* 模式 chip:带左侧 SVG 图标暗示「这是切换器」。
旧版只显示文字 + tooltip,用户不知道点了会切;现在一眼能看出是控件。*/
.status-chip-mode {
gap: 4px;
}
.status-chip-mode svg {
flex: 0 0 auto;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.status-chip-mode:hover svg {
opacity: 1;
}
/* 字数 chip:是状态栏的核心信息 —— 文字降彩度(--accent-text),
让大段数字 / 字符长时间停留在状态栏里时不"刺眼"。背景仍带 accent 含义。 */
.status-chip-stats {
background: var(--accent-soft);
color: var(--accent-text);
font-weight: 600;
}
/* 可点击 chip:状态栏的「模式」现在可直接点击切换 */
.status-chip-clickable {
border: none;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
.status-chip-clickable:hover {
background: var(--bg-active);
filter: brightness(1.08);
}
.status-chip-clickable:active {
transform: scale(0.95);
}
/* 当前 heading:高亮显示阅读视图当前的章节名(轻量 breadcrumb) */
.status-heading {
flex: 0 1 auto;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 260px;
padding: 5px 8px;
border-radius: var(--radius-sm);
color: var(--fg-primary);
font-weight: 500;
font-size: var(--fs-sm);
letter-spacing: var(--tracking-wide);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-heading::before {
/* § 符号暗示「章节」 */
content: "§";
color: var(--fg-tertiary);
font-family: var(--font-mono);
font-size: var(--fs-xs-readable);
flex: 0 0 auto;
}
.status-chip-muted {
background: transparent;
color: var(--fg-tertiary);
font-weight: 500;
}
.status-chip[data-mode="edit"],
.status-chip[data-mode="split"] {
background: var(--accent-soft);
/* 11px 字号 + accent 文字在 accent-soft 背景上仅 ~3.6:1;
用正文链接同色相的 --md-link(≈5.5:1 on dark)替代 */
color: var(--md-link);
}
/* unsaved chip:替代原本在 toolbar 上靠脏态按钮传递信息的旧设计。
状态栏上的 chip 是「当前文档的真实状态」,比 toolbar 按钮更准;
warning 色 + 圆点,让用户即使不在编辑视图也能一眼看到「未保存」。
文字用 --warning-text(60% 纯色 + 40% fg-primary):纯橙小字会因高
chroma 振动,看着累;前 6px 圆点已经承担纯色警示,文字降彩度即可。 */
.status-chip-unsaved {
background: color-mix(in srgb, var(--warning) 16%, transparent);
color: var(--warning-text);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
}
.status-chip-unsaved::before {
/* 圆点标识:替代可能过大的图标,6px 与 toolbar status-dot 同尺度 */
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warning);
/* 微微辉光,呼应 dirty-pulse 动画语义 */
box-shadow: 0 0 0 2px color-mix(in srgb, var(--warning) 24%, transparent);
}
/* ============================================================
AI chip:状态栏新增,承载 AI 过程/错误/待确认状态 + dock 开关入口。
设计要点([feedback-ai-tips-into-status-bar]):
- idle / success / cancelled → 中性 muted,不抢视觉权重(与 .status-chip 默认对齐)
- running → accent-soft + 脉冲点,提示「正在工作」
- diff → accent-soft + --md-link,让「中间面板有 N 处待确认」一眼可见
- error → danger 色 + 14% transparent bg,最强警示但不刺眼
不引入新色彩 token:复用 --accent / --accent-soft / --danger / --md-link / --fg-tertiary。
============================================================ */
.status-chip-ai {
/* 默认 idle:与 .status-chip 默认同款(bg-hover + primary text) */
display: inline-flex;
align-items: center;
gap: 5px;
/* AI chip 走 [data-ai-state] 在 idle / running / diff / error / success / cancelled
之间切换,每个态都有自己的 background + color。120ms 平滑过渡,
"AI 思考中..." → "AI: 3 处待确认" → "AI 已完成" 让色阶连成一条线 */
transition:
background var(--transition-fast),
color var(--transition-fast);
}
/* running:accent-soft 底 + 脉冲点(与原 ai-thinking-pulse 同款 keyframe 搬到下面)。
文字降一档彩度(--accent-text),由脉冲点承担纯色,
避免「AI 思考中…」纯 accent 小字长时间占据视觉中心看着累。 */
.status-chip-ai[data-ai-state="running"] {
background: var(--accent-soft);
color: var(--accent-text);
}
.status-chip-ai[data-ai-state="running"] .status-chip-ai-text::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: 5px;
border-radius: 50%;
background: var(--accent);
vertical-align: 1px;
animation: status-ai-pulse 1.4s ease-in-out infinite;
}
@keyframes status-ai-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent);
opacity: 0.7;
}
50% {
box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 0%, transparent);
opacity: 1;
}
}
/* error:danger 14% 透明 + danger 文字 —— 强警示但不全屏铺红。
文字降一档彩度,避免「AI 调用失败」红字在状态栏里"刺眼"。
左右侧 dot/border 仍是纯 danger 色,整体警示强度不丢。 */
.status-chip-ai[data-ai-state="error"] {
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--danger-text);
}
/* info:超长提示词等软警告(audit Phase L3-AI 1)—— accent 8% 透明 + accent
文字,比 error 弱、比 success 强;不脉冲不闪烁,避免干扰用户继续打字。
文字降彩度,背景继续承载 accent 意味。 */
.status-chip-ai[data-ai-state="info"] {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent-text);
}
/* diff:与 mode chip 的 [data-mode] 变体同款(accent-soft + --md-link),
让「中间面板有 N 处待确认」与「当前是 split / edit 模式」视觉权重一致。
用户已经习惯在状态栏右段看到这类「重要 action chip」。 */
.status-chip-ai[data-ai-state="diff"] {
background: var(--accent-soft);
color: var(--md-link);
font-weight: 600;
}
/* success / cancelled:透明底 + tertiary 文字,短暂停留后由下次操作覆盖 */
.status-chip-ai[data-ai-state="success"],
.status-chip-ai[data-ai-state="cancelled"] {
background: transparent;
color: var(--fg-tertiary);
}
/* ============================================================
Toast
============================================================ */
.toast-container {
position: fixed;
/* 跟随状态栏高度,再加一格间距让 toast 浮在状态栏上方不贴边 */
bottom: calc(var(--h-statusbar) + var(--space-2));
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
}
.toast {
pointer-events: auto;
/* 上下 12px / 左右 16px;旧值 12px/24px 偏窄;icon + 文案的布局需要更多内边距 */
padding: 11px 16px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--fg-primary);
font-size: var(--fs-sm);
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: toast-in 280ms var(--ease-out);
max-width: 480px;
white-space: pre-wrap;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
/* 可选 icon:用 grid 而不是 inline-flex 是为了 icon 缺失时布局也稳定 */
display: inline-grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
}
/* icon 缺失时不留空 column —— 用 :has 反向检测浏览器支持,
不支持时降级到 grid-template-columns: 1fr(文字独占一列) */
@supports selector(:has(*)) {
.toast:not(:has(.toast-icon)) {
grid-template-columns: 1fr;
}
}
.toast-icon {
/* 图标:16px,与文字 baseline 视觉对齐 */
flex: 0 0 auto;
width: 16px;
height: 16px;
color: currentColor;
}
.toast.is-success {
border-left: 3px solid var(--success);
color: var(--fg-primary);
}
.toast.is-success .toast-icon {
color: var(--success);
}
.toast.is-warning {
border-left: 3px solid var(--warning);
}
.toast.is-warning .toast-icon {
color: var(--warning);
}
.toast.is-error {
border-left: 3px solid var(--danger);
}
.toast.is-error .toast-icon {
color: var(--danger);
}
.toast.is-info {
border-left: 3px solid var(--accent);
}
.toast.is-info .toast-icon {
color: var(--accent);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

1190
src/styles/08-modal.css Normal file

File diff suppressed because it is too large Load Diff

251
src/styles/09-misc.css Normal file
View File

@@ -0,0 +1,251 @@
/* ===========================================================================
Scrollbar + Context Menu + Boot error
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ============================================================
Scrollbar
============================================================ */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
background-clip: padding-box;
border: 2px solid transparent;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* ============================================================
Context Menu(右键菜单:阅读视图 + 编辑器共用)
============================================================ */
.context-menu {
position: fixed;
z-index: var(--z-modal);
min-width: 160px;
padding: var(--space-1);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 2px;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
animation: context-menu-in 120ms var(--ease-out);
user-select: none;
/* 阻止菜单自身的文本被选中 */
-webkit-user-select: none;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-4);
font-size: var(--fs-sm);
font-weight: 500;
color: var(--fg-primary);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
white-space: nowrap;
}
/* 右键菜单 hover:纯 --accent 文字在 accent-soft 底上"同色相对撞"。
改用 --accent-text:背景已明确"hover 中",文字降彩度避免菜单里每一行
hover 时都"跳"出来一份硬彩文字。 */
.context-menu-item:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent-text);
}
.context-menu-item:active:not(:disabled) {
/* 给一点按下反馈 */
background: color-mix(in srgb, var(--accent-soft) 80%, transparent);
}
.context-menu-item:disabled {
color: var(--fg-tertiary);
cursor: not-allowed;
opacity: 0.55;
}
.context-menu-separator {
height: 1px;
background: var(--border-subtle);
margin: var(--space-1) calc(var(--space-1) * 1);
}
@keyframes context-menu-in {
from {
opacity: 0;
transform: scale(0.96) translateY(-2px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* ============================================================
Boot error fallback
============================================================ */
#boot-error {
position: fixed;
inset: 0;
background: var(--bg-app);
color: var(--fg-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-9);
z-index: var(--z-boot-error);
text-align: center;
}
#boot-error h2 {
color: var(--danger);
margin-bottom: var(--space-3);
font-size: var(--fs-2xl);
}
#boot-error p {
margin-bottom: var(--space-3);
color: var(--fg-secondary);
}
/* ============================================================
Focus Mode —— 隐藏所有 chrome,只留阅读视图
============================================================ */
/* 工具栏、侧栏、状态栏全部隐藏(保留定位以避免布局重排) */
body[data-focus-mode="true"] .toolbar,
body[data-focus-mode="true"] .sidebar,
body[data-focus-mode="true"] .sidebar-splitter,
body[data-focus-mode="true"] .statusbar,
body[data-focus-mode="true"] .ai-chat-dock {
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast, 120ms ease-out);
}
/*
BUG-3 fix (audit):focus mode 下 .ai-chat-dock 即便被 opacity:0 隐藏,
仍然占据 body grid row 3 的自然高度(~88px),把 app-shell 高度挤掉。
这里在 focus mode 下用 display:none 真正释放 grid 空间;
用户退出 focus mode 后 controller 会重新挂 dock(见 ai-controller.bindMountPoint)。
*/
body[data-focus-mode="true"] .ai-chat-dock {
display: none;
}
/*
BUG-2 fix (audit):focus mode + AI 同时打开时,dock 已 display:none 但
.app-ai 中间面板仍可见 —— 用户能看 diff 但无法输入新 prompt,违反 focus 语义。
这里把 .app-ai 也隐藏。退出 focus 后 ai-controller._syncGridFlag 重新切回
body[data-ai-diff-open="true"],面板恢复可见。
*/
body[data-focus-mode="true"] .app-ai {
display: none;
}
/* 工具栏从顶部滑出(默认隐藏,hover 顶边热区时滑入) */
body[data-focus-mode="true"] .toolbar {
transform: translateY(-100%);
transition: transform 180ms ease-out, opacity 180ms ease-out;
}
/* 顶部「热区」:默认脱离 grid 流(不影响 body 三行布局),
聚焦模式才显示。body 是 display:grid,若让 .focus-hotzone 占普通 grid cell
会抢走 row1(52px),把 .toolbar 挤到 row2、.app-shell 挤到 row3 状态栏行,
整个主区塌掉。默认 display:none + position:fixed 让它不占格、不挡操作;
聚焦模式再开启 display:block + pointer-events:auto 接管事件。 */
.focus-hotzone {
position: fixed;
top: 0; left: 0; right: 0;
width: auto;
height: 14px;
z-index: var(--z-toolbar, 100);
display: none;
pointer-events: none;
}
body[data-focus-mode="true"] .focus-hotzone {
display: block;
pointer-events: auto;
}
body[data-focus-mode="true"]:has(.focus-hotzone:hover) .toolbar {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* 聚焦模式时,去掉 app-shell 的网格第一列(侧栏)和状态栏高度,
整个窗口让位给阅读视图。
状态栏占位约 28px,去掉后内容自动撑满;
侧栏 width: 0 让 reader/viewer 直接吃满 viewport。 */
body[data-focus-mode="true"] .app-shell {
grid-template-columns: 0 0 minmax(0, 1fr);
}
body[data-focus-mode="true"][data-editor-mode="split"] .app-shell {
grid-template-columns: 0 0 minmax(0, 1fr);
}
body[data-focus-mode="true"][data-editor-mode="split"] .editor-pane,
body[data-focus-mode="true"][data-editor-mode="split"] .splitter {
display: none !important;
}
/* 退出聚焦的提示:聚焦模式时显示一个右下角的「退出」按钮 + 状态条。
平时默认隐藏;鼠标在窗口内悬停 600ms 后显出,避免一进入 focus 就跳提示。 */
.focus-exit-hint {
position: fixed;
bottom: 16px;
right: 16px;
z-index: var(--z-toolbar, 100);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: 6px 12px;
font-size: var(--fs-xs);
color: var(--fg-tertiary);
box-shadow: var(--shadow-md);
opacity: 0;
transition: opacity 600ms ease-out;
pointer-events: none;
}
.focus-exit-hint kbd {
padding: 1px 5px;
border: 1px solid var(--border-default);
border-radius: 3px;
background: var(--bg-card);
font-family: var(--font-mono);
font-size: 10px;
margin: 0 1px;
}
body[data-focus-mode="true"]:hover .focus-exit-hint {
opacity: 1;
pointer-events: auto;
}

69
src/styles/10-a11y.css Normal file
View File

@@ -0,0 +1,69 @@
/* ===========================================================================
a11y 工具类:skip link + prefers-reduced-motion
自动从 src/styles.css 拆分(Stage 4a)
=========================================================================== */
/* ---------- skip link (Phase M) ----------
键盘用户 Tab 时第一站直达主内容(#viewer-main),跳过 9 个工具栏按钮 +
侧栏若干入口 + 排序切换。
- 默认视觉隐藏(不占布局、不抢 mouse 焦点),focus 时滑出右上角
- 用 `:focus-visible` 而非 `:focus`:鼠标点击不触发(避免每次点击
主页都闪一下右上角)
- z-index 高于 toolbar(toolbar 顶层 100),保证焦点环可见
*/
.skip-link {
position: absolute;
top: 8px;
right: 8px;
z-index: 1000;
padding: 8px 14px;
background: var(--accent, #4f8cff);
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
/* 默认视觉隐藏但保留可访问性 —— 用 transform 移到屏外,不影响
屏幕阅读器在第一个 Tab 焦点候选里看到它 */
transform: translateY(-200%);
transition: transform 120ms ease-out;
}
.skip-link:focus,
.skip-link:focus-visible {
transform: translateY(0);
outline: 2px solid #fff;
outline-offset: 2px;
}
/* skip link target —— #viewer-main 拿到焦点时去掉默认 outline
(它是"已抵达目的地"的反馈,不是交互元素),但保留 SR focus 语义 */
.viewer:focus,
.viewer:focus-visible {
outline: none;
}
/* ---------- prefers-reduced-motion ----------
关掉持续脉冲 (dirty-pulse / halo / ai-pulse) 与一次性入场动画
(modal / toast / ai-diff / ai-chat / outline-slide)。
用 0.01ms 而非 0 / animation: none:保留渲染状态(toast/modal 还要显示),
只是让用户「感知不到」动画过程。
兜底使用 * 选择器覆盖未来新增 keyframes,避免遗漏。
关键修复:去掉 `transition-duration: 0.01ms !important` —— 之前 blanket 会
杀掉所有 hover/focus 过渡(按钮悬停高亮、tab 切换、focus 环),让前庭敏感
用户失去所有交互反馈(与禁用动画的初衷相反)。
transition 是「用户主动操作触发的短暂反馈」(平均 120ms),不是「持续脉冲」
这类持续性刺激;前者对前庭敏感用户基本无害,后者才是真正需要关的。
见 [[feedback-ui-detail-polish]] —— 体验与无障碍要兼顾。
*/
@media (prefers-reduced-motion: reduce) {
.btn-icon.is-dirty,
.btn-icon-text.is-dirty { animation: none; }
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0 !important;
}
}

714
src/styles/11-ai.css Normal file
View File

@@ -0,0 +1,714 @@
/* ============================================================================
AI 修改功能(参考 markdown.guanjihuan.com 的设计思路)
- 中间面板 .app-ai:app-shell 的 grid 第 6 列,body[data-ai-diff-open] 控制可见
- 底部 dock .ai-chat-dock:body grid 第 3 行(app-shell 与 statusbar 之间),
由 grid-row:3 显式钉位;默认 hidden 不占空间,展开时 statusbar 自动让位
- Diff 渲染:行级 +/- + 词级 inline diff(equal/removed/added)
- 配色与现有 token 一致(accent / success / warning / danger / surface)
============================================================================ */
/* ============================================================
Body 网格:插入 AI 中间面板
============================================================ */
/*
AI 中间面板在三种模式下的位置(参考 markdown.guanjihuan.com 的
「编辑 / 预览中间夹 AI 审查区」设计):
- preview(只显示 viewer):AI 在 viewer 右边
- edit(只显示 editor):AI 在 editor 右边
- split(editor + viewer 双栏):AI 在 #splitter 右边、viewer 左边
—— 即「编辑和预览中间」的位置,用户对照原文档 / 编辑器的同时看 AI diff
AI 默认 hidden,由 ai-controller 切到 body[data-ai-diff-open="true"] 时显示。
*/
/*
BUG fix:CSS Grid auto-placement 按 DOM 顺序处理 cursor,cursor 越过列尾就
wrap 到下一行。.app-ai / .ai-splitter 都是显式钉列,.viewer 是 auto。
在 preview / edit 模式下,editor-pane / splitter 是 hidden 不占格,cursor
会跳过它们继续前进:sidebar→col 1、sb-splitter→col 2,再前进到 col 3 时
没有 hidden 项要放、cursor 继续走;如果 .app-ai 显式 col 5 让 cursor
走到 row 末,下面的 .ai-splitter(显式 col 4)和 .viewer(auto)就会被
全部挤到 row 2。
grid-auto-flow: dense 让算法回填 row 1 的空位:显式项尽量就近放、auto
项也能填中间空隙。配合 DOM 把 .app-ai / .ai-splitter 放在 .viewer 之前
(见 index.html 注释),所有模式(preview / edit / split / focus)
都自动落到正确列。
*/
body[data-ai-diff-open="true"] .app-shell {
grid-auto-flow: dense;
}
/* preview/edit + 仅 AI:5 列
sidebar(1) | sb-splitter(2) | main(3) | ai-splitter(4) | AI(5) */
body[data-ai-diff-open="true"] .app-shell {
grid-template-columns:
var(--sidebar-width)
4px
minmax(0, 1fr)
var(--ai-splitter-width, 4px)
minmax(220px, var(--w-ai, 360px));
}
/* split + 仅 AI:7 列 —— AI 在 #splitter 和 viewer 之间(中间位置)
sidebar(1) | sb-splitter(2) | editor(3) | #splitter(4) | AI(5) | ai-splitter(6) | viewer(7)
- editor 用 px (--w-editor),viewer 用 1fr 自动填剩余 —— 拖 ai-splitter 时 AI 增长 px、
fr space 缩小,viewer 缩小但 editor 不动;拖 #splitter 时 editor 增长 px、
viewer 缩小、AI 不动。
- ai-splitter 控制 AI 宽度(px,独立)
- editor 宽度由 mountSplitter 在 .app-shell 的 style 上写 --w-editor */
body[data-ai-diff-open="true"][data-editor-mode="split"] .app-shell {
grid-template-columns:
var(--sidebar-width)
4px
minmax(280px, var(--w-editor, 50%))
4px
minmax(220px, var(--w-ai, 360px))
var(--ai-splitter-width, 4px)
minmax(0, 1fr);
}
/* preview/edit + AI:5 列(见上) */
/*
聚焦模式(focus-mode)优先于 AI 网格 —— 11-ai.css 比 09-misc.css 后加载,
默认会被覆盖成「侧栏 + AI」。这里用更高特异性的选择器把侧栏收起,
只留「viewer + AI 中间面板」两列。
focus 模式下 AI 面板单独占一列;split 也退化成单栏(与 09-misc.css 的 focus 简化一致)。
*/
body[data-focus-mode="true"][data-ai-diff-open="true"] .app-shell,
body[data-focus-mode="true"][data-ai-diff-open="true"][data-editor-mode="split"] .app-shell {
grid-template-columns:
0
0
minmax(0, 1fr)
0
var(--ai-splitter-width, 4px)
minmax(220px, var(--w-ai, 360px));
}
/*
窄窗口(< 720px):让 AI 中间面板与 viewer 共享空间,避免 viewer 被挤到 0px。
同样限制主进程的最小宽度为 600,实际只影响 600~720 区间。
Bug-1 fix (audit):这里必须把 .ai-splitter 也一起 display:none —— 否则
媒体查询把 grid-template 压成 3 列,但 .ai-splitter 仍带 grid-column:5/6/7/8,
CSS Grid 会为了满足显式列号自动扩展隐式列,把 4px 宽的 splitter 推到
viewer 旁边成为一个"孤儿"光标条。app.js 的 mountAiSplitter 命中检查
也已经依赖这个隐藏(否则窄屏下拖一个看不见的 splitter 写入 --w-ai)。
*/
@media (max-width: 720px) {
body[data-ai-diff-open="true"] .app-shell,
body[data-ai-diff-open="true"][data-editor-mode="split"] .app-shell {
grid-template-columns: var(--sidebar-width) 4px minmax(0, 1fr);
}
body[data-ai-diff-open="true"] .app-ai,
body[data-ai-diff-open="true"] .ai-splitter {
display: none; /* 窄屏默认关闭 AI 中间面板;用户可用 toolbar AI 按钮查看 */
}
}
/*
BUG-1 fix (audit):.app-ai 必须显式声明 grid-column,否则会被
grid-auto-flow: row 推到 4px 间隔列,宽度被压成 4px(看似"消失了")。
模板列数随模式不同,所以这里按场景分别钉列。
新设计(AI 在 split 模式放中间)的 grid 列映射:
| 场景 | 列数 | ai-splitter | app-ai |
| ------------------- | ---- | ----------- | ------ |
| preview/edit + AI | 5 | 4 | 5 |
| split + AI | 7 | 6 | 5 | ← AI 在 #splitter 和 viewer 之间
| focus + AI | 6 | 5 | 6 | ← focus 退化为单栏
*/
/* preview/edit + AI:5 列 */
body[data-ai-diff-open="true"][data-editor-mode="preview"] .ai-splitter,
body[data-ai-diff-open="true"][data-editor-mode="edit"] .ai-splitter {
grid-column: 4;
}
body[data-ai-diff-open="true"][data-editor-mode="preview"] .app-ai,
body[data-ai-diff-open="true"][data-editor-mode="edit"] .app-ai {
grid-column: 5;
}
/* split + AI:AI 嵌在 #splitter(col 4) 和 viewer(col 7) 之间
7 列:sb(1) | sb-splitter(2) | editor(3) | #splitter(4) | AI(5) | ai-splitter(6) | viewer(7) */
body[data-ai-diff-open="true"][data-editor-mode="split"] .app-ai {
grid-column: 5;
}
body[data-ai-diff-open="true"][data-editor-mode="split"] .ai-splitter {
grid-column: 6;
}
/* focus + AI(split 在 focus 下退化为单栏):6 列 */
body[data-focus-mode="true"][data-ai-diff-open="true"] .ai-splitter {
grid-column: 5;
}
body[data-focus-mode="true"][data-ai-diff-open="true"] .app-ai {
grid-column: 6;
}
/* ============================================================
.ai-splitter 拖动条(视觉 4px + 命中区扩展)
============================================================ */
/*
与 #splitter / #sidebar-splitter 同套设计:视觉宽度 4px,但命中区通过
::before / ::after 伪元素左右各扩 6px,让鼠标接近边缘就能抓住。
视觉上的「薄线」用渐变显示 hover 态。
*/
.ai-splitter {
background: transparent;
cursor: col-resize;
/* 默认位置:grid 自定(grid-column 由各模式场景单独钉) */
position: relative;
/* 阻止浏览器默认拖选 / 文本选择 */
user-select: none;
touch-action: none;
}
.ai-splitter::before {
content: '';
position: absolute;
top: 0; bottom: 0;
/* 视觉条居中,宽度 4px */
left: 50%;
width: var(--ai-splitter-width, 4px);
transform: translateX(-50%);
background: var(--border-subtle);
transition: background var(--transition-fast, 120ms ease-out);
}
.ai-splitter:hover::before,
.ai-splitter.is-dragging::before {
background: var(--accent);
}
.ai-splitter:focus-visible {
outline: none;
}
.ai-splitter:focus-visible::before {
box-shadow: 0 0 0 2px var(--accent);
}
/*
focus 模式下 AI 面板也整体隐藏(与 .app-ai 同步)—— 用户已经进入
「只看 viewer」状态,splitter 出现反而提醒宽度可调,分散注意力。
*/
body[data-focus-mode="true"] .ai-splitter {
display: none;
}
/* ============================================================
.app-ai 中间面板
============================================================ */
.app-ai {
background: var(--bg-sidebar);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
/* 入场动画:从右往左 8px,淡入 150ms */
animation: ai-diff-enter 150ms ease-out;
}
@keyframes ai-diff-enter {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
/* ============================================================
Diff 面板头部
============================================================ */
.ai-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
gap: var(--space-3);
}
.ai-diff-title {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--fg-primary);
margin: 0 0 2px;
}
.ai-diff-summary {
/* 11px + --fg-muted 在 --bg-card 上仅 ~3.2:1(dark default),低于 AA。
这是"5 处变更" / "+12 / -3"这类用户主动扫的统计数字,提一档到
--fg-tertiary(~5.4:1)让 11px 信息也能稳定看清。
字重 500 加粗辅助,让小字号+低饱和组合仍可读 */
font-size: var(--fs-xs);
color: var(--fg-tertiary);
font-weight: 500;
margin: 0;
}
.ai-diff-actions {
display: flex;
align-items: center;
gap: 6px;
}
.ai-diff-apply-all {
height: 26px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--btn-on-accent);
border: 1px solid var(--accent);
font-size: var(--fs-xs-readable);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.ai-diff-apply-all:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.ai-diff-apply-all:active {
transform: scale(0.98);
}
.ai-diff-apply-all:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ai-diff-dismiss {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: transparent;
color: var(--fg-muted);
border: 1px solid var(--border-subtle);
font-size: var(--fs-base);
line-height: 1;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
}
.ai-diff-dismiss:hover {
color: var(--fg-primary);
border-color: var(--border-default);
background: var(--accent-soft);
}
.ai-diff-dismiss:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================
Diff 主体(行级 + 词级)
============================================================ */
.ai-diff-fulltext {
flex: 1;
min-height: 0;
overflow: auto;
font-family: var(--font-mono, ui-monospace, Menlo, Consolas, monospace);
font-size: var(--fs-xs-readable);
line-height: 1.55;
padding: 8px 0;
}
/* 单行:行号 + +/-/space 前缀 + 文本 */
.ai-diff-line {
display: grid;
/* 行号列从 36px 收到 32px:999 行以内(~32px 够放 3 位数字 + 6px padding)
已经覆盖 99% 文档;超过 999 的极端情况用 ellipsis 截断 */
grid-template-columns: 32px 16px 1fr;
align-items: baseline;
padding: 1px 14px 1px 0;
white-space: pre-wrap;
word-break: break-word;
}
.ai-diff-line-added { background: color-mix(in srgb, var(--success) 12%, transparent); }
.ai-diff-line-removed { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.ai-diff-line-context { color: var(--fg-secondary); }
.ai-diff-line-plain { grid-template-columns: 1fr; }
.ai-diff-line-number {
color: var(--fg-muted);
text-align: right;
padding-right: 8px;
user-select: none;
font-variant-numeric: tabular-nums;
/* audit fix (CSS P1 / diff line numbers opacity removed):
旧实现 opacity: 0.65 在 --fg-muted 之上再叠一层 alpha。对比度从原本
约 4.5:1 跌到 ~3:1(叠透明度 + 暗色底),line number 看着像「水印」
而不是结构信息 —— 用户来回滚 diff 时眼睛定位行号很费劲。--fg-muted
本身在各 palette 都已经过 AA 调整(02-palettes.css 注释),单独用
这个色就足够弱化但仍可读,去掉 opacity 让对比度回到 palette 设计
目标值。
*/
}
.ai-diff-line-prefix {
color: var(--fg-muted);
text-align: center;
user-select: none;
}
.ai-diff-line-added .ai-diff-line-prefix { color: var(--success); font-weight: 600; }
.ai-diff-line-removed .ai-diff-line-prefix { color: var(--danger); font-weight: 600; }
.ai-diff-line-text {
color: var(--fg-primary);
}
/* 词级 inline diff */
.ai-diff-segment { white-space: pre-wrap; }
.ai-diff-segment-removed {
/* 词级删除:底色 + 删除线。
底色透明度从 24% 降到 18%,因为叠加行级 12% 后总透度接近 30%,
行内 highlight 看起来太重。叠两层后 18% 让"行"和"词"的视觉权重拉开 */
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--fg-primary);
border-radius: 2px;
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--danger) 60%, transparent);
text-decoration-thickness: 1px;
}
.ai-diff-segment-added {
background: color-mix(in srgb, var(--success) 18%, transparent);
color: var(--fg-primary);
border-radius: 2px;
}
/* ============================================================
Diff Region(修改块):每个 region 一个 section + 头部 + 主体
============================================================ */
.ai-diff-region {
/* region 之间靠 border-top + margin 8px 隔开;不要底部边框,
否则下一个 region 的顶部会和自己的底部边线"撞"成 2px */
border-top: 1px solid var(--border-subtle);
margin: 8px 0;
/* region 是"修改块",用 30% bg-elevated 微微抬起,让眼睛能快速定位。
配合圆角 + 微阴影让每个 region 像一张卡片 */
background: color-mix(in srgb, var(--bg-elevated) 30%, transparent);
border-radius: var(--radius-sm);
}
.ai-diff-region:first-child {
/* 第一个 region 顶部贴顶,不需要 border-top */
border-top: none;
margin-top: 0;
}
.ai-diff-region-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: color-mix(in srgb, var(--bg-elevated) 60%, transparent);
border-bottom: 1px solid var(--border-subtle);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.ai-diff-region-apply {
height: 24px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.ai-diff-region-apply:hover:not([disabled]) {
background: var(--accent);
color: var(--btn-on-accent);
}
.ai-diff-region-apply:active:not([disabled]) {
transform: scale(0.98);
}
.ai-diff-region-apply[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* audit fix:conflict 区域新增的"跳过此处"按钮 —— 比 apply 更弱的视觉权重,
让用户优先尝试 apply,失败时才有第二选项。 */
.ai-diff-region-actions {
display: inline-flex;
gap: 6px;
align-items: center;
}
.ai-diff-region-skip {
height: 24px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: transparent;
/* 与 apply 按钮成对:apply 用 accent 主色,skip 用 muted 次色 —— 形成视觉权重差,
让用户在冲突时优先尝试 apply,skip 才是退路 */
color: var(--fg-secondary);
border: 1px solid var(--border-subtle);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.ai-diff-region-skip:hover {
background: var(--bg-hover);
color: var(--fg-primary);
border-color: var(--border-default);
}
.ai-diff-region-skip:active {
transform: scale(0.98);
}
.ai-diff-conflict {
font-size: var(--fs-xs);
/* 文字降彩度(--warning-text):"冲突"两字若用纯橙小字与行内 +/- 同色
saturate,易在密集 diff 块里"跳出来"。左侧 +/- 行号前缀用纯色,
文字让一档,保持可识别但不抢戏。 */
color: var(--warning-text);
margin-left: auto;
margin-right: 8px;
}
.ai-diff-region-body {
/* 内部每行延用 .ai-diff-line */
}
/* ============================================================
空状态 / 普通回复(AI 返回纯文本,不改文档时)
============================================================ */
.ai-diff-empty {
/* 上下加大间距,左右收敛到 16px,让中间文案看起来「居中呼吸」。
用 flex column + gap 而不是一个段落,方便未来插入图标 */
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 32px 16px;
color: var(--fg-tertiary);
font-size: var(--fs-sm);
text-align: center;
line-height: var(--lh-relaxed);
}
/* (audit fix Round 9: .ai-diff-empty-icon / .ai-diff-empty-title /
.ai-diff-empty-hint / .ai-reply-plain 全部为零引用死 CSS —— ai-diff-panel.js
只用 .ai-diff-empty 容器,suffix 三个变体没有调用方) */
.ai-reply-content {
flex: 1;
min-height: 0;
overflow: auto;
/* 上下 18px,左右 16px:比之前的 12px/14px 更舒展,
AI 回复通常字数多,给点上下呼吸让长文不挤 */
padding: 18px 16px;
}
.ai-reply-content.markdown-body {
/* 复用 .markdown-body 的字体/排版(参见 06-viewer.css) */
}
/* (audit fix Round 9: .ai-reply-plain 死 CSS,已在文件头注释) */
/* ============================================================
底部 AI 对话 dock
挂在 app-shell 与 statusbar 之间,侧栏式 UI 风格
============================================================ */
.ai-chat-dock {
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border-top: 1px solid var(--border-subtle);
/* 上下 8px 收紧 dock 高度:原来 10px 让 dock 总高 76px,输入框下沿到
状态栏之间有 ~34px 空白(含 status row 始终保留的 16px),视觉偏空。
收到 8px 后空闲高度 60px 左右,更紧凑又不挤 */
padding: 8px 16px;
gap: 4px;
/* 入场动画:自下而上淡入 */
animation: ai-chat-enter 150ms ease-out;
}
@keyframes ai-chat-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-chat-form-row {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: 8px;
min-height: 32px;
}
.ai-chat-toggle {
width: 28px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: transparent;
color: var(--fg-muted);
border: 1px solid var(--border-subtle);
font-size: var(--fs-base);
line-height: 1;
cursor: pointer;
transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
}
.ai-chat-toggle:hover {
color: var(--fg-primary);
border-color: var(--border-default);
background: var(--accent-soft);
}
.ai-chat-input-wrap {
display: flex;
align-items: center;
min-width: 0;
height: 32px;
}
.ai-chat-input {
flex: 1;
min-width: 0;
height: 32px;
/* padding: 0 10px 完整重置 UA 默认 padding(Chromium 给 input 是 padding: 1px 2px),
避免 1px 上下 padding 让 height 计算偏移。border-box 下 32 - 2(border) = 30 内容区。 */
padding: 0 10px;
background: var(--bg-viewer);
color: var(--fg-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--fs-sm);
/* line-height: 30 让单行文字在 30px 内容区内垂直居中(避免 line-height: normal
按 1.2× 计算导致基线偏移)。 */
line-height: 30px;
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.ai-chat-input:hover:not(:focus) {
border-color: var(--border-default);
}
.ai-chat-input:focus {
outline: none;
border-color: var(--accent);
background: var(--bg-app);
/* 微微发光,让 focus 视觉反馈更明确,配合 outline 不会"双圈" */
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* 键盘焦点反馈:移除默认 outline 后,键盘用户(Tab 切换)看不到当前位置。
:focus-visible 只在键盘聚焦时触发,鼠标点击不显示,保持视觉简洁。
修复 audit #9:之前 box-shadow 在 :focus 上无条件生效,键盘聚焦时与下方
outline 同时存在造成「3px 外发光 + 2px 描边」双圈;现在 :focus-visible
时清掉 box-shadow,只保留更明确的 outline。 */
.ai-chat-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
box-shadow: none;
}
.ai-chat-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-chat-input::placeholder {
/* 从 fg-muted(暗色 ~2:1 几乎看不见)提到 fg-secondary(~8.5:1),
与搜索框一致 —— AI 输入也是核心入口,placeholder 必须看清 */
color: var(--fg-tertiary);
}
.ai-chat-submit {
height: 32px;
/* P2-6 fix (audit):min-width 锁定宽度,防止"生成修改"(4字)和
"取消"/"取消中…"(2/4字)切换时按钮宽度跳变 */
min-width: 76px;
padding: 0 14px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--btn-on-accent);
border: 1px solid var(--accent);
font-size: var(--fs-sm);
font-weight: 500;
/* 与 input 一致:32(总高) - 2(border) = 30 内容区,让按钮文字与输入框
文字在同一基线垂直居中。 */
line-height: 30px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.ai-chat-submit:hover:not([disabled]) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.ai-chat-submit:active:not([disabled]) {
transform: scale(0.98);
}
.ai-chat-submit[data-mode="cancel"] {
background: transparent;
color: var(--warning);
border-color: var(--warning);
}
.ai-chat-submit[data-mode="cancel"]:hover:not([disabled]) {
background: var(--warning);
color: var(--bg-viewer);
}
.ai-chat-submit[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.ai-chat-submit:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ============================================================
AI 按钮激活态(侧栏底部)
当 AI 面板展开时给 btn-ai 一个明显区别于"主操作描边态"的实心强调态:
- 关闭:沿用 .sidebar-new-btn 的描边 + 8% accent 底(与「新建笔记」同款)
- 打开:实心 accent 底 + 白字 + accent 边,让用户一眼看出 AI dock 已开
用 #btn-ai.sidebar-new-btn 这种 ID+class 组合是为了 specificity 高过
.sidebar-new-btn:hover(0,2,0),保证 hover 时不会把"已打开"的视觉态冲掉。
============================================================ */
#btn-ai.sidebar-new-btn[aria-pressed="true"],
#btn-ai.sidebar-new-btn[aria-pressed="true"]:hover {
color: var(--btn-on-accent, #fff);
background: var(--accent);
border-color: var(--accent);
}
#btn-ai.sidebar-new-btn[aria-pressed="true"]:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}

185
src/styles/12-print.css Normal file
View File

@@ -0,0 +1,185 @@
/* ===========================================================================
打印样式(@media print)
自动从 src/styles.css 拆分(audit #60 critical fix)
Notes 是阅读器,用户打印长笔记很常见。默认打印会把工具栏 / 侧栏 /
状态栏 / AI 面板 / 模态 / toast / 焦点模式提示全部印出来——
既浪费纸,又把内容挤到不可读的边角。
目标:打印 = 只剩干净的阅读视图 + 黑白配色 + 合理边距。
=========================================================================== */
@media print {
/* 隐藏所有应用 chrome —— 阅读视图之外都不进纸 */
.toolbar,
.sidebar,
.sidebar-splitter,
.statusbar,
.app-ai,
.ai-splitter,
.ai-chat-dock,
.modal-overlay,
.toast-container,
.focus-hotzone,
.focus-exit-hint,
.boot-error,
#modal-root {
display: none !important;
}
/* 重置 app-shell 网格 —— 移除多栏布局,让正文占满整页 */
.app-shell,
body {
display: block !important;
grid-template-columns: none !important;
grid-template-rows: none !important;
background: white !important;
color: black !important;
}
/* 阅读视图:完整内容 + 合理边距 + 取消 max-width 限制 */
.viewer,
.editor-pane,
.markdown-body {
background: white !important;
color: black !important;
max-width: none !important;
width: 100% !important;
height: auto !important;
overflow: visible !important;
padding: 0 !important;
margin: 0 !important;
}
/* 阅读正文:1.5cm 边距 + serif fallback(用户机器可能没装 Source Serif Pro)。
audit fix (CSS P2 / print doubled margin):之前在 .markdown-body 上又
叠一层 padding:1.5cm,与 @page margin:1.5cm 1.8cm 加起来 = 上下 3cm
/ 左右 3.3cm,每页可用空间被吃光。@page margin 已经把页面边距定好,
这里只保留字体 / 字号 / 行高 / 链接色,不该再动 padding。 */
.markdown-body {
font-family: var(--font-reading, 'Source Serif Pro', 'Noto Serif CJK SC', Georgia, 'Times New Roman', serif) !important;
font-size: 11pt !important;
line-height: 1.5 !important;
color: black !important;
}
/* Markdown 内容:纯黑白 + 强对比 + 取消背景色(节省墨水) */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: black !important;
background: none !important;
page-break-after: avoid;
page-break-inside: avoid;
}
.markdown-body h1 { font-size: 22pt !important; }
.markdown-body h2 { font-size: 18pt !important; }
.markdown-body h3 { font-size: 14pt !important; }
.markdown-body p,
.markdown-body li,
.markdown-body blockquote {
color: black !important;
orphans: 3;
widows: 3;
}
.markdown-body a {
color: black !important;
text-decoration: underline;
}
/* 打印时显示 URL(让读者知道链接指向哪里)—— 链接文本后面追加 href
audit fix (#8 shared):加 overflow-wrap: anywhere + word-break: break-all,
否则长 URL(github issue 链接、含 query string 的 API 文档等)在 A4 宽度下
会溢出页面右侧,超出可打印边距;保留 word-break 在伪元素上也生效。 */
.markdown-body a[href]::after {
content: ' (' attr(href) ')';
font-size: 0.85em;
color: #555 !important;
overflow-wrap: anywhere;
word-break: break-all;
}
/* 但文内锚点(#xxx)不显示 —— 没意义 */
.markdown-body a[href^="#"]::after {
content: '';
}
.markdown-body blockquote {
border-left: 3px solid #888 !important;
background: none !important;
padding-left: 12pt !important;
}
.markdown-body pre,
.markdown-body code {
color: black !important;
background: #f4f4f4 !important;
border: 1px solid #ddd !important;
font-family: 'Consolas', 'Menlo', 'Courier New', monospace !important;
font-size: 9.5pt !important;
page-break-inside: avoid;
}
.markdown-body code {
/* 行内 code 不需要 box border,看起来更接近正常文本 */
border: none !important;
background: #f4f4f4 !important;
padding: 0 3pt !important;
}
.markdown-body table {
border-collapse: collapse !important;
page-break-inside: avoid;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #888 !important;
background: white !important;
color: black !important;
padding: 4pt 8pt !important;
}
.markdown-body img {
max-width: 100% !important;
height: auto !important;
page-break-inside: avoid;
}
.markdown-body hr {
border: none !important;
border-top: 1px solid #888 !important;
background: none !important;
margin: 12pt 0 !important;
}
/* 避免在标题 / 表格 / 图片内分页 —— 阅读体验更连贯 */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body figure,
.markdown-body pre,
.markdown-body table,
.markdown-body img,
.markdown-body blockquote {
page-break-inside: avoid;
}
/* 取消所有 hover/focus 视觉态(打印场景没意义,且会浪费墨水) */
*,
*::before,
*::after {
box-shadow: none !important;
text-shadow: none !important;
transition: none !important;
animation: none !important;
}
/* 页面设置:A4 + 合理边距 */
@page {
size: A4;
margin: 1.5cm 1.8cm;
}
}

44
src/toolbar.js Normal file
View File

@@ -0,0 +1,44 @@
// 工具栏组件
// 绑定主题切换 / 置顶 / 设置 / 窗口控件按钮的事件。
//
// 与 Todo List 的 toolbar.js 区别:去掉了「添加 todo」+ 搜索框 + 视图切换,
// 只留下窗口控制 + 应用级开关。
export class Toolbar {
/**
* @param {object} options
* @param {object} options.elements - 按钮元素引用
* @param {() => void} options.onThemeClick
* @param {() => void} options.onPinClick
* @param {() => void} options.onSettingsClick
* @param {() => void} options.onResetLayoutClick
* @param {() => void} options.onMinimize
* @param {() => void} options.onToggleMaximize
* @param {() => void} options.onClose
*/
constructor({ elements, onThemeClick, onPinClick, onSettingsClick, onResetLayoutClick, onMinimize, onToggleMaximize, onClose }) {
this.elements = elements;
if (elements.btnTheme && onThemeClick) {
elements.btnTheme.addEventListener('click', onThemeClick);
}
if (elements.btnPin && onPinClick) {
elements.btnPin.addEventListener('click', onPinClick);
}
if (elements.btnSettings && onSettingsClick) {
elements.btnSettings.addEventListener('click', onSettingsClick);
}
if (elements.btnResetLayout && onResetLayoutClick) {
elements.btnResetLayout.addEventListener('click', onResetLayoutClick);
}
if (elements.btnMin && onMinimize) {
elements.btnMin.addEventListener('click', onMinimize);
}
if (elements.btnMax && onToggleMaximize) {
elements.btnMax.addEventListener('click', onToggleMaximize);
}
if (elements.btnClose && onClose) {
elements.btnClose.addEventListener('click', onClose);
}
}
}

341
src/utils.js Normal file
View File

@@ -0,0 +1,341 @@
// 共享小工具 —— 文本格式化与转义
//
// 单一来源:之前 formatSize / formatDate 在 file-list.js 和
// markdown-viewer.js 各定义了一份;escapeHtml / escapeAttr 在
// settings-dialog.js 和 confirm-dialog.js 也重复了一遍。
// 时间常量 —— formatRelativeTime 内部需要「秒」「天」量级;提出来便于阅读
const MS_PER_SEC = 1000;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* 文件大小 -> 人类可读字符串(B / KB / MB)
* @param {number} bytes
*/
export function formatSize(bytes) {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* mtime -> 短日期字符串(YYYY-MM-DD HH:mm)
* @param {number} mtimeMs
*/
export function formatDate(mtimeMs) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
const d = new Date(mtimeMs);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${y}-${mo}-${da} ${h}:${mi}`;
}
/**
* mtime -> 相对时间字符串(用于侧栏文件列表的密度提升)
*
* 阈值梯度(基于 now):
* < 60s → "刚刚"
* < 60min → "N 分钟前"
* < 24h(且跨日)→ "昨天"
* < 24h(同日) → "N 小时前"
* < 7d → "N 天前"
* ≥ 7d → 回落到 formatDate(绝对时间)
*
* @param {number} mtimeMs
* @param {number} [nowMs=Date.now()]
* @returns {string}
*/
export function formatRelativeTime(mtimeMs, nowMs = Date.now()) {
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) nowMs = Date.now();
const diffMs = Math.max(0, nowMs - mtimeMs);
const sec = Math.floor(diffMs / MS_PER_SEC);
if (sec < 60) return '刚刚';
const min = Math.floor(sec / 60);
if (min < 60) return `${min} 分钟前`;
// 跨日判断:基于本地"今天 0:00"的差值
const mtime = new Date(mtimeMs);
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfMtimeDay = new Date(mtime.getFullYear(), mtime.getMonth(), mtime.getDate()).getTime();
const daysApart = Math.round((startOfToday - startOfMtimeDay) / MS_PER_DAY);
if (daysApart === 0) {
const hr = Math.floor(min / 60);
return `${hr} 小时前`;
}
if (daysApart === 1) return '昨天';
if (daysApart < 7) return `${daysApart} 天前`;
// ≥ 7d:回落绝对时间,避免"183 天前"这种无意义表达
return formatDate(mtimeMs);
}
/**
* 转义 HTML 文本节点内容
*/
export function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 安全渲染 markdown —— markdown-viewer / ai-diff-panel 共享的入口。
*
* 优先走 preload 暴露的 window.api.renderMarkdown(marked + DOMPurify 走主进程加载,
* XSS 防护集中)。失败 / 不可用时退化为转义后的 <pre>,永远不返回未转义的原文。
*
* @param {string} markdown
* @returns {string} 安全 HTML(可放入 markdown-body 容器)
*/
export function safeRenderMarkdown(markdown) {
if (typeof window !== 'undefined'
&& window.api
&& typeof window.api.renderMarkdown === 'function') {
try {
const result = window.api.renderMarkdown(markdown || '');
// audit fix (Round 8 R-1):renderMarkdown 契约是 string,但 jsdom 单测或
// 未来 mock 替换可能返回非字符串。typeof guard 防 `null / undefined / 数字`
// 直接 setBody 触发下游 DOM 解析错或 .innerHTML=null 抛错。**必须用 typeof,
// 不能用 `||`**,因为 '' 是空 markdown 的合法返回(preload 对空字符串返回 '',
// 不是 <pre>)—— `result ||` 会把合法空 markdown 误降级。
if (typeof result === 'string') return result;
console.warn('[utils] renderMarkdown 返回非字符串,降级为 <pre>:', typeof result);
} catch (e) {
console.warn('[utils] renderMarkdown 失败,降级为 <pre>:', e && e.message);
}
} else if (typeof window !== 'undefined') {
console.warn('[utils] window.api.renderMarkdown 不可用,降级为 <pre>');
}
return `<pre class="md-render-fallback">${escapeHtml(markdown || '')}</pre>`;
}
/**
* 转义 HTML 属性值。
*
* 与 escapeHtml 等价 —— escapeHtml 已经覆盖了 `"` 和 `'`(→ &quot; / &#39;),
* 直接放进属性上下文(`attr="..."` / `attr='...'`)都是安全的。
*
* 这里保留独立导出有两个目的:
* - 语义标识:调用方看到 escapeAttr 就知道这是放进 HTML 属性的;
* review 时一眼能区分「文本节点」与「属性值」两个不同的转义场景
* - 未来扩展:若要给属性转义加更激进的规则(如拒绝控制字符),
* 改这一处即可,不必把所有 escapeHtml 调用点扫一遍
*
* @param {string} s
*/
export function escapeAttr(s) {
return escapeHtml(s);
}
/**
* 当前是否在 Windows 上 —— 用于大小写不敏感比较。
*
* 优先用 navigator.userAgentData(Chromium 90+ 官方接口,未来主流);
* 退回 navigator.platform(已废弃但 Electron 仍可用);
* 再退回 UA 字符串嗅探。
*
* 注意:本函数跑在 renderer 里,process 不存在 —— 不能写 process.platform。
*
* @returns {boolean}
*/
function isWindows() {
if (typeof navigator === 'undefined') return false;
// Chromium 90+: userAgentData.platform 形如 'Windows' / 'macOS' / 'Linux'
if (navigator.userAgentData && typeof navigator.userAgentData.platform === 'string') {
return navigator.userAgentData.platform.toLowerCase().includes('win');
}
// navigator.platform 已被 W3C 标记废弃,但 Chromium 内核仍支持;Electron 渲染端可用
if (typeof navigator.platform === 'string' && navigator.platform) {
if (/^win/i.test(navigator.platform)) return true;
}
// 兜底:UA 字符串嗅探
return /Windows NT/i.test(navigator.userAgent || '');
}
/**
* 检查 child 路径是否在 parent 目录之内(防御性边界检查)。
* 不依赖文件系统(不解析 symlink),只做字符串前缀比对。
* Windows 下忽略大小写。
*
* 与 main.js 的 isWithinDataDir 行为一致 —— 这里只是 renderer 端的快速兜底。
*
* 边界:
* - parent 为根目录(POSIX '/'、Windows 'C:\')时,norm 会把末尾分隔符
* 剥掉变成空串或纯盘符;这种「根」视为「任何非空路径都在其内」。
* - child 为空串 → 不视为在任何目录内(路径非法)。
*
* @param {string} child
* @param {string} parent
*/
export function isPathInside(child, parent) {
if (!child || !parent) return false;
const norm = (s) => s.replace(/\\/g, '/').replace(/\/+$/, '');
let a = norm(parent);
let b = norm(child);
if (!b) return false; // 空 child 路径不视为合法
if (isWindows()) {
a = a.toLowerCase();
b = b.toLowerCase();
}
// 根目录边界:norm 之后 a 是空串(POSIX '/')或仅含盘符(Windows 'C:')。
// 这两种情况下任何非空 child 都在 parent 内 —— 视 a 为「空前缀」。
if (a === '' || /^[a-z]:$/i.test(a)) {
return true;
}
if (a === b) return true;
return b.startsWith(a + '/');
}
/**
* 平台相关的路径相等比较。
*
* Windows 路径大小写不敏感(NTFS 默认),但 macOS / Linux 大小写敏感。
* 同样两条路径在 Windows 上要求 .toLowerCase() 相等;其他平台要求严格相等。
*
* 用法:sidebar / file-list 把当前打开文件(state.currentFile.path,可能是
* 用户输入的 case)与磁盘条目(fs 返回的真实 case)做匹配时,
* `state.currentFile.path === entry.path` 在 Windows 上经常 false,
* 导致高亮 / 选中态错位。统一走 pathsEqual 兜住。
*
* @param {string|null|undefined} a
* @param {string|null|undefined} b
* @returns {boolean}
*/
export function pathsEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
return isWindows() ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* 取路径所在目录(不含末尾分隔符)。
* 纯字符串操作,renderer 里没有 path 模块可用。
* @param {string} filePath
* @returns {string}
*/
export function dirnameOf(filePath) {
if (!filePath || typeof filePath !== 'string') return '';
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
if (idx < 0) return '';
// 保留 POSIX 根目录的那个 '/'
if (idx === 0) return '/';
return filePath.slice(0, idx);
}
/**
* 取路径的最后一段(文件名)。
* @param {string} filePath
* @returns {string}
*/
export function basenameOf(filePath) {
if (!filePath || typeof filePath !== 'string') return '';
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
return idx < 0 ? filePath : filePath.slice(idx + 1);
}
/**
* 把相对路径按 baseDir 解析成绝对路径,并折叠 '.' / '..'。
*
* 这里必须自己实现而不能简单地 split+join:POSIX 绝对路径的第一段是空串,
* 过滤空串再 join 会把开头的 '/' 吃掉('/home/a' → 'home/a')。
* 所以先记住「根前缀」('/' 或 'C:\'),只对其后的段做折叠。
*
* @param {string} baseDir - 绝对目录
* @param {string} relative - 相对路径(也接受绝对路径,此时原样规范化)
* @returns {string|null} 绝对路径;越过根目录时返回 null
*/
export function resolvePath(baseDir, relative) {
if (typeof relative !== 'string' || !relative) return null;
const winAbs = /^[a-z]:[\\/]/i.test(relative);
const posixAbs = relative.startsWith('/');
const input = (winAbs || posixAbs) ? relative : `${baseDir}/${relative}`;
// 提取根前缀:Windows 盘符 或 POSIX '/'
let root = '';
let rest = input;
const driveMatch = /^([a-z]:)[\\/]/i.exec(input);
if (driveMatch) {
root = driveMatch[1] + '\\';
rest = input.slice(driveMatch[0].length);
} else if (input.startsWith('/')) {
root = '/';
rest = input.slice(1);
}
const sep = root.includes('\\') ? '\\' : '/';
const stack = [];
for (const part of rest.split(/[\\/]/)) {
if (part === '' || part === '.') continue;
if (part === '..') {
if (stack.length === 0) return null; // 越过根目录 → 拒绝
stack.pop();
} else {
stack.push(part);
}
}
return root ? root + stack.join(sep) : stack.join(sep);
}
/**
* Markdown 扩展名数组(小写、不含点)。
* 来源:shared/extension-lists.js 经 preload contextBridge 暴露(window.api.MARKDOWN_EXTS)。
* 这里仅做 fallback:preload 失败(极少见)时降级到硬编码 ['md','markdown'],
* 不让 isMarkdownFile 抛错拖死 bootstrap。
*/
function getMarkdownExts() {
const fromApi = (typeof window !== 'undefined') && window.api && window.api.MARKDOWN_EXTS;
if (Array.isArray(fromApi)) return fromApi;
return ['md', 'markdown'];
}
/**
* 文件路径是否为 Markdown(按扩展名判定)。
*
* 大小写不敏感;隐藏文件(.gitignore)返回 false。
*
* @param {string} filePath
* @returns {boolean}
*/
export function isMarkdownFile(filePath) {
if (!filePath || typeof filePath !== 'string') return false;
const name = basenameOf(filePath);
// 隐藏文件 / 没有扩展名 / 末尾是点 → 不是 md
if (!name || name.startsWith('.') || !name.includes('.')) return false;
const dot = name.lastIndexOf('.');
if (dot === name.length - 1) return false;
const ext = name.slice(dot + 1).toLowerCase();
return getMarkdownExts().includes(ext);
}
/**
* 本地绝对路径 → file:// URL。
* 用于把 markdown 里的相对图片路径改写成能被 <img> 真正加载的地址
* (否则会相对 index.html 所在的应用目录解析,永远 404)。
*
* @param {string} absPath
* @returns {string}
*/
export function toFileUrl(absPath) {
if (!absPath || typeof absPath !== 'string') return '';
let p = absPath.replace(/\\/g, '/');
if (!p.startsWith('/')) p = '/' + p; // Windows 的 'C:/x' → '/C:/x'
// 逐段编码,保留 '/';# 和 ? 必须编码,否则会被当成 fragment / query
const encoded = p.split('/').map(encodeURIComponent).join('/');
// 盘符里的冒号不要留成 %3A —— 保持 file:///C:/... 这种 Chromium 最认的形式
return 'file://' + encoded.replace(/^\/([a-z])%3A\//i, '/$1:/');
}
// 注:countWords 已迁移到 ./stats.js(CJK 友好词数 + 行数 + chars 的统一来源)。