update
This commit is contained in:
453
src/ai/ai-chat-panel.js
Normal file
453
src/ai/ai-chat-panel.js
Normal 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
864
src/ai/ai-controller.js
Normal 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
268
src/ai/ai-diff-panel.js
Normal 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
72
src/ai/ai-status.js
Normal 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
3959
src/app.js
Normal file
File diff suppressed because it is too large
Load Diff
118
src/component.js
Normal file
118
src/component.js
Normal 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
127
src/confirm-dialog.js
Normal 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
285
src/context-menu.js
Normal 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
29
src/dirty-sync.js
Normal 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
135
src/editor-theme.js
Normal 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
82
src/editor-toolbar.js
Normal 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
50
src/event-emitter.js
Normal 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
68
src/feedback.js
Normal 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
476
src/file-list.js
Normal 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
481
src/file-ops.js
Normal 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
173
src/focus-trap.js
Normal 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
445
src/markdown-editor.js
Normal 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
736
src/markdown-viewer.js
Normal 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
55
src/modal-stack.js
Normal 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
328
src/modal.js
Normal 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
242
src/prompt-dialog.js
Normal 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
46
src/save-button-ui.js
Normal 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
83
src/save-toast.js
Normal 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
1002
src/settings-dialog.js
Normal file
File diff suppressed because it is too large
Load Diff
225
src/settings-store.js
Normal file
225
src/settings-store.js
Normal 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
206
src/shortcuts.js
Normal 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
260
src/splitter.js
Normal 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
76
src/stats.js
Normal 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
18
src/styles.css
Normal 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
105
src/styles/01-tokens.css
Normal 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
500
src/styles/02-palettes.css
Normal 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
159
src/styles/03-base.css
Normal 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
565
src/styles/04-toolbar.css
Normal 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
872
src/styles/05-sidebar.css
Normal 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
295
src/styles/06-markdown.css
Normal 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
446
src/styles/07-statusbar.css
Normal 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
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
251
src/styles/09-misc.css
Normal 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
69
src/styles/10-a11y.css
Normal 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
714
src/styles/11-ai.css
Normal 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
185
src/styles/12-print.css
Normal 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
44
src/toolbar.js
Normal 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
341
src/utils.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全渲染 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 已经覆盖了 `"` 和 `'`(→ " / '),
|
||||
* 直接放进属性上下文(`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 的统一来源)。
|
||||
Reference in New Issue
Block a user