This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -55,6 +55,22 @@ function inferStatusType(text) {
return 'success';
}
/**
* P2-23:决策 AI 面板 placeholder 三态文案。
*
* 优先级:!hasFile 永远盖过 !configured —— 即便用户既没打开文件又没配 AI,
* 也只提示「请先打开文件」,避免一屏两行矛盾提示。配置态仅在有文件
* 场景下区分。
*
* @param {{hasFile: boolean, configured: boolean}} state
* @returns {string}
*/
export function pickPlaceholder({ hasFile, configured }) {
if (!hasFile) return '请先打开一个 Markdown 文件';
if (!configured) return 'AI 未配置 — 点「⚙ 设置」填 Base URL 与 API Key';
return '描述你想如何修改当前文档...';
}
export class AiChatPanel {
/**
* @param {object} options
@@ -63,12 +79,15 @@ export class AiChatPanel {
* @param {(requestId:string) => void} options.onCancel
* @param {(text:string, type:'idle'|'running'|'cancelled'|'error'|'success'|'diff') => void} [options.onNotify]
* 把过程/错误文案转发到外部 UI(statusbar 上的 #status-ai chip)。不传则静默丢弃。
* @param {() => void} [options.onOpenSettings] P2-23:未配置时点击「⚙ 设置」内联链调用。
* 不传则按钮 click 无效果(依然可见,让用户看见状态)。
*/
constructor({ element, onSubmit, onCancel, onNotify }) {
constructor({ element, onSubmit, onCancel, onNotify, onOpenSettings }) {
this.element = element;
this.onSubmit = onSubmit;
this.onCancel = onCancel;
this.onNotify = typeof onNotify === 'function' ? onNotify : null;
this.onOpenSettings = typeof onOpenSettings === 'function' ? onOpenSettings : null;
this._open = false;
this._enabled = true;
this._submitting = false;
@@ -83,6 +102,18 @@ export class AiChatPanel {
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
const toggleBtn = this.element.querySelector('.ai-chat-toggle');
const configureLink = this.element.querySelector('.ai-chat-configure-link');
if (configureLink) {
// P2-23:未配置时显示的「⚙ 设置」内联链;点击调 onOpenSettings(如果有)。
// 即使没传 callback,按钮也显示 —— 让用户看见状态差异,避免「为什么 AI 不工作」困惑。
this._listeners.push([configureLink, 'click', (e) => {
e.preventDefault();
if (this.onOpenSettings) {
try { this.onOpenSettings(); } catch { /* 回调失败不影响 UI */ }
}
}]);
}
if (input) {
this._listeners.push([input, 'input', () => {
@@ -224,13 +255,40 @@ export class AiChatPanel {
*/
setEnabled(enabled) {
this._enabled = !!enabled;
this._syncInputState();
}
/**
* P2-23:通知面板 AI 配置状态变化,刷新 placeholder + 内联设置链。
* settings 保存 / 清空 API key 都会触发,placeholder 实时切换「未配置」↔「描述修改」。
* @param {boolean} configured
*/
setConfigured(configured) {
this._configured = !!configured;
this._syncInputState();
}
/**
* P2-23:把 hasFile + configured 同步到 input / submit / configure-link。
* 抽出避免 setEnabled / setConfigured 各算一遍。
* @private
*/
_syncInputState() {
const input = this.element.querySelector('.ai-chat-input');
const submitBtn = this.element.querySelector('.ai-chat-submit');
const configureLink = this.element.querySelector('.ai-chat-configure-link');
const hasFile = this._enabled;
const configured = this._configured !== false; // 默认 true,避免未初始化中间态误判
if (input) {
input.disabled = this._inputShouldBeDisabled();
input.placeholder = this._enabled ? '描述你想如何修改当前文档...' : '请先打开一个 Markdown 文件';
input.placeholder = pickPlaceholder({ hasFile, configured });
}
if (submitBtn) submitBtn.disabled = this._inputShouldBeDisabled();
if (configureLink) {
// 内联链:仅在「有文件 + 未配置」时显示。!hasFile 时被「请先打开文件」盖过,
// 不必再多一个入口。
configureLink.hidden = !hasFile || configured;
}
}
/**

View File

@@ -28,6 +28,19 @@ import { AiChatPanel } from './ai-chat-panel.js';
import { AiDiffPanel } from './ai-diff-panel.js';
import { AI_STATUS, AI_ERROR, aiErrorMessage } from './ai-status.js';
/**
* 取 pendingDiff 的「不可变提交快照」:originalBaseContent 优先,缺时回退
* baseContent。apply / skip 漂移判定统一用此函数 —— 避免每个调用点重复
* `originalBaseContent || baseContent` 表达式,也方便后续把 baseContent
* 拆为「可变重算基线」/「不可变提交快照」两个独立字段时只改一处。
* @param {{originalBaseContent?: string, baseContent?: string} | null | undefined} pd
* @returns {string}
*/
function getImmutableBase(pd) {
if (!pd) return '';
return pd.originalBaseContent || pd.baseContent || '';
}
/**
* 给一段行级 lines 构造只读 context rows(用于 AI 普通回复展示)。
* 行号必须与 shared/markdown-diff.js 的 splitLines 严格一致 —— 否则 viewer
@@ -274,9 +287,18 @@ export function createAiController({
// 走 onNotify → app.js 的 updateStatusAi → statusbar 上的 #status-ai chip。
// 详见 [feedback-ai-tips-into-status-bar]。
onNotify: notify,
// P2-23:未配置时点击「⚙ 设置」内联链直接打开设置对话框。
// 与 togglePanel 中 !isConfigured() → onOpenSettings 同源。
onOpenSettings: () => {
if (typeof onOpenSettings === 'function') {
try { onOpenSettings(); } catch { /* ignore */ }
}
},
});
// 一开始禁用(直到用户打开文件)
chatPanel.syncFileState(!!state.currentFile);
// P2-23:bootstrap 时同步 AI 配置态(影响 placeholder 三态文案 + 内联设置链)
chatPanel.setConfigured(isConfigured());
diffPanel = new AiDiffPanel({
element: diffElement,
@@ -367,9 +389,12 @@ export function createAiController({
const isRaw = result.responseFormat === 'raw';
if (isRaw) {
// AI 没改文档,只是回复一段文字
// originalBaseContent 必须填:apply 漂移判断靠它(与下方非 readOnly 分支对齐),
// 缺它会回退到 baseContent,被 recompute 覆写后漂移保护失效。
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
originalBaseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: true,
rows: linesToContextRows(aiContent),
@@ -391,10 +416,13 @@ export function createAiController({
if (!diffResult.regions || diffResult.regions.length === 0) {
// AI 返回内容与原文一致(已经过 schema 比较过,但 baseContent 可能在
// 期间被外部改过,所以再次校验)
// 期间被外部改过,所以再次校验)。
// 与非 readOnly 分支保持对称:都设 originalBaseContent = contentSnapshot,
// 让下游读到 originalBaseContent || baseContent 时语义一致。
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
originalBaseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: true,
rows: linesToContextRows(aiContent),
@@ -408,6 +436,7 @@ export function createAiController({
setPendingDiff({
id: result.id,
baseContent: contentSnapshot,
originalBaseContent: contentSnapshot,
nextContent: aiContent,
isReadOnly: false,
rows: diffResult.rows,
@@ -545,7 +574,9 @@ export function createAiController({
// 「编辑器的当前实际内容」—— 后续任何重算 / onFileChanged 比对都依赖它。
// 旧顺序里如果 aiDiffCompute 抛错,编辑器是新内容、pendingDiff.baseContent
// 还是旧内容,用户看到「已应用的修改没体现在剩余 diff 里」的诡异行为。
pendingDiff = { ...pendingDiff, baseContent: result.content };
// 同时把 originalBaseContent 也同步推进到 result.content —— 否则后续
// 「应用全部」会与残留的 submit-time 快照对比误判漂移,强制 readOnly。
pendingDiff = { ...pendingDiff, baseContent: result.content, originalBaseContent: result.content };
// preview 模式修复:apply 后立即通知外部刷新 viewer。split 模式下
// scheduleLivePreview 会覆盖(防双触发不致命 —— loadContentLive 幂等)。
if (typeof onAfterApply === 'function') {
@@ -623,7 +654,10 @@ export function createAiController({
// 或者撤销自己的编辑后重提。最坏情况保留全删路(用户主动重提后 baseContent
// 重新对齐,apply 不再丢失任何东西)。
const currentForApply = safeGetContent();
if (currentForApply !== null && currentForApply !== pendingDiff.baseContent) {
// 漂移判定必须用「提交时的不可变快照」originalBaseContent,而非 pendingDiff.baseContent
// —— 后者会被 recomputeDiffFromBase 在每次用户输入后覆写为当前内容,导致本保护永远不触发、
// 「应用全部」静默覆盖用户等待期间的手动修改(数据丢失)。
if (currentForApply !== null && currentForApply !== getImmutableBase(pendingDiff)) {
if (typeof toast === 'function') {
toast('你在等待 AI 时已修改过文档,「应用全部」会覆盖未在 diff 内的修改,已切换为逐条确认', 'warning', 4000);
}
@@ -695,9 +729,11 @@ export function createAiController({
}
let nextRows = filteredRows;
let nextRegions = filtered;
let nextBase = pendingDiff.baseContent;
let nextBase = getImmutableBase(pendingDiff);
const skippedRegion = (pendingDiff.regions || []).find((r) => r.id === regionId);
if (current !== pendingDiff.baseContent && pendingDiff.nextContent) {
// 漂移判定同 handleApplyAll:用提交时的不可变快照 originalBaseContent,
// 避免 recompute 把 baseContent 覆写为当前内容后,漏掉"用户等待期间已编辑"的重算。
if (current !== getImmutableBase(pendingDiff) && pendingDiff.nextContent) {
try {
const recomputed = api.aiDiffCompute(current, pendingDiff.nextContent, pickDiffOptions(current, pendingDiff.nextContent));
if (recomputed && Array.isArray(recomputed.rows) && Array.isArray(recomputed.regions)) {
@@ -742,7 +778,11 @@ export function createAiController({
pendingDiff = {
...pendingDiff,
// originalBaseContent 跟随 nextBase 推进:下一次 skip / apply 的漂移
// 判定窗口不再包含本步跳过造成的编辑差异,否则「跳 1 → 再跳 1」
// 都会被假漂移触发重算。
baseContent: nextBase,
originalBaseContent: nextBase,
rows: nextRows,
regions: nextRegions,
};
@@ -911,5 +951,12 @@ export function createAiController({
hasPendingDiff: () => !!pendingDiff,
onFileChanged,
onContentChanged,
// P2-23:settings 改了(Base URL / API Key / Model)后调用,
// 把最新配置态推给 chatPanel 让 placeholder 立即切换「未配置」↔「描述修改」。
refreshConfigured: () => {
if (chatPanel && typeof chatPanel.setConfigured === 'function') {
chatPanel.setConfigured(isConfigured());
}
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -117,7 +117,7 @@ export function showConfirmDialog({ title, message, buttons = [], icon }) {
// (app.js 外部修改冲突)把 modal-occupied 误识别为用户主动选择「暂不处理」
// → 静默推进 lastSavedMtimeMs → 后续外部修改被吞。改为返回 null,让调用方
// 能区分「对话框没出来」与「用户主动取消」。
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
showToast('已有对话框打开,请先关闭后再试', 'warning');
resolve(null);
return;
}

41
src/editor-mode-reset.js Normal file
View File

@@ -0,0 +1,41 @@
// splitRatio 重置决策 —— applyEditorMode 内部按视觉模式决定是否重置。
// ============================================================================
//
// 背景(audit P0-4 + Memory-2):
// - 编辑双栏(splitRatio)每次「打开」都默认对半分,drag 比例仅本次会话内
// 有效,不跨应用重启保留。
// - 旧实现用 `document.body.dataset.editorMode !== 'split'` 做闸 —— 但默认
// editorMode='split' 冷启动时 dataset 已是 'split',闸永远为假,上次拖的
// 70% 在冷启动被恢复。
// - 修复:按 visualMode 决定(不再依赖 DOM dataset / state.editorMode)——
// visualMode='split' 即重置,其它不动。包括非 md 文件强制回落 'edit'
// 的场景,自然不重置。
//
// 错误处理:settingsStore.update 失败用 onError 上报(默认 console.warn),
// 不抛出 —— 布局重置是非关键路径,写盘失败不应让 app 挂掉。
//
// mountSplitter.onDragEnd 同步改为 noop(drag 不再持久化),留在 app.js
// commit 里人工 review,不在本文件。
/**
* 视觉模式为 'split' 时,重置 settings.splitRatio 到 0.5;其它模式不动。
*
* @param {string} visualMode - 解析后的视觉模式('preview' | 'edit' | 'split')
* @param {{ update: (partial: object) => Promise<*> }} settingsStore
* @param {(msg: string) => void} [onError] - settingsStore.update 失败上报;
* 默认 console.warn(不抛,避免布局重置错误炸掉 app)
* @returns {void}
*/
export function applySplitRatioResetIfEnteringSplit(visualMode, settingsStore, onError) {
if (visualMode !== 'split') return;
// 已经是 0.5 就跳过写盘:本函数会在每次打开 / 模式切换 / save() finally 里被调用,
// 若每次都写 splitRatio:0.5,会对 settings 文件产生无意义的写入(触发额外的
// scan / 写入竞争)。settingsStore.get 存在时才短路(不存在则照旧写,保持旧行为)。
if (typeof settingsStore.get === 'function' && settingsStore.get('splitRatio') === 0.5) {
return;
}
const report = onError || ((msg) => console.warn(msg));
settingsStore.update({ splitRatio: 0.5 }).catch((e) => {
report(`布局比例重置失败:${e?.message || '未知错误'}`);
});
}

View File

@@ -68,6 +68,24 @@ function fuzzyScore(name, lowerQuery) {
return score;
}
// 合法 sortMode 白名单 —— setSort 与构造器都走它,避免漏判 / 拼写错误导致静默
// 退回到 'name'。新排序模式只在 SETTINGS_UI_OPTIONS.fileListSort 与本常量同时更新。
const SORT_MODES = new Set(['name', 'name-desc', 'mtime-desc', 'mtime-asc']);
function normalizeSortMode(mode) {
return SORT_MODES.has(mode) ? mode : 'name';
}
// audit fix (R7a P1-15):windowed 虚拟滚动常量。
// - FILE_ITEM_HEIGHT 必须与 .file-item 的 height CSS 同步(src/styles/05-sidebar.css)
// 改一边忘了另一边会出"位置算出来但渲染时错位"。
// - OVERSCAN 是「视口外多渲几个」的缓冲,跨边界滚动时不出现短暂空白。
// 5 个 = 250px 缓冲,肉眼看不出延迟。
// - SENTINEL_CLASS 是撑出滚动条高度的占位 <li>,不渲染内容、aria-hidden。
const FILE_ITEM_HEIGHT = 50;
const FILE_ITEM_OVERSCAN = 5;
const SENTINEL_CLASS = 'file-list-sentinel';
const WINDOWED_CLASS = 'is-windowed';
/**
* 文件列表组件
*/
@@ -77,20 +95,36 @@ export class FileList {
* @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'
* @param {string} [options.sortMode='name'] - 排序模式:'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'
*/
constructor({ element, searchInput, onSelect, onContextMenu, sortMode = 'name' }) {
constructor({ element, searchInput, onSelect, onContextMenu, onContextMenuEmpty, onDropIntoFolder, sortMode = 'name' }) {
this.element = element;
this.searchInput = searchInput;
this.onSelect = onSelect;
this.onContextMenu = onContextMenu;
// 空白区域右键:没命中任何 file-item 时回调(弹「粘贴到当前目录」菜单)。
this.onContextMenuEmpty = typeof onContextMenuEmpty === 'function' ? onContextMenuEmpty : null;
// 拖拽到文件夹:把条目拖到 folder 项上时回调(move;按住 Ctrl/Cmd 时 copy)。
this.onDropIntoFolder = typeof onDropIntoFolder === 'function' ? onDropIntoFolder : null;
// 当前正在拖拽的源条目(dragstart 写入、dragend 清空)。
// dataTransfer 在 dragover 阶段读不到自定义数据,所以源靠这里保存。
this._dragEntry = null;
this.files = [];
this.filteredFiles = [];
this.activePath = null;
this.searchTerm = '';
this.sortMode = sortMode === 'mtime-desc' ? 'mtime-desc' : 'name';
this.sortMode = normalizeSortMode(sortMode);
/** path → file,供事件委托时反查(避免每个 <li> 挂一个闭包) */
this._byPath = new Map();
// audit fix (R7a P1-15):windowed 虚拟滚动状态。
// - _sentinel:撑出滚动条高度的占位 <li>
// - _pool:复用的 <li> 池,scroll 时只 flush textContent + classList
// - _windowed:当前是否在 windowed 模式(决定 render 走哪条路径)
// - _scrollFrame:rAF token,scroll 事件节流
this._sentinel = null;
this._pool = [];
this._windowed = false;
this._scrollFrame = null;
// 统一 lifecycle:dispose() 一次性 unbind 所有 listener。
createDisposable(this);
@@ -128,13 +162,101 @@ export class FileList {
}
}, { signal: this.signal });
// 右键菜单:弹给调用方处理(context-menu.js)
// 右键菜单:命中 file-item 弹条目菜单;空白区域弹「粘贴到当前目录」。
element.addEventListener('contextmenu', (e) => {
const file = this._fileFromEvent(e);
if (!file || !this.onContextMenu) return;
e.preventDefault();
this.onContextMenu(file, e.clientX, e.clientY);
if (file && this.onContextMenu) {
e.preventDefault();
this.onContextMenu(file, e.clientX, e.clientY);
return;
}
// 没命中条目(列表空白 / 滚动条留白)→ 给调用方弹粘贴菜单
if (this.onContextMenuEmpty) {
e.preventDefault();
this.onContextMenuEmpty(e.clientX, e.clientY);
}
}, { signal: this.signal });
// 拖拽:把文件 / 文件夹拖到其他条目。
// 仅 folder 条目是合法投放目标;dragstart 记录源条目,dragover/drop 用事件
// 委托反查目标 folder。move = 普通拖拽,copy = 按住 Ctrl/Cmd 拖拽。
// 用 createDisposable 的 signal 统一解绑(dispose 时自动移除)。
element.addEventListener('dragstart', (e) => {
const li = e.target.closest && e.target.closest('.file-item');
if (!li) return;
const entry = this._byPath.get(li.dataset.path);
if (!entry) return;
this._dragEntry = entry;
try {
e.dataTransfer.setData('text/plain', entry.path);
e.dataTransfer.setData('application/x-notes-path',
JSON.stringify({ path: entry.path, entryType: entry.entryType, name: entry.name }));
} catch { /* 某些浏览器 setData 受限,但 drag 仍可继续 */ }
e.dataTransfer.effectAllowed = (e.ctrlKey || e.metaKey) ? 'copy' : 'move';
li.classList.add('is-dragging');
}, { signal: this.signal });
element.addEventListener('dragover', (e) => {
if (!this._dragEntry) return;
const li = e.target.closest && e.target.closest('.file-item');
this._clearDropTargets();
if (!li) { this._setDropEffect(e, 'none'); return; }
const target = this._byPath.get(li.dataset.path);
if (!target || target.entryType !== 'folder' || this._isInvalidDropTarget(this._dragEntry, target)) {
this._setDropEffect(e, 'none');
return;
}
e.preventDefault(); // 允许 drop 的必要前提
this._setDropEffect(e, (e.ctrlKey || e.metaKey) ? 'copy' : 'move');
li.classList.add('is-drop-target');
}, { signal: this.signal });
// 真正离开列表(relatedTarget 跑到列表外)才清高亮;子项之间移动会冒泡
// dragleave,但 relatedTarget 仍在列表内,不误清。
element.addEventListener('dragleave', (e) => {
if (e.relatedTarget && element.contains(e.relatedTarget)) return;
this._clearDropTargets();
}, { signal: this.signal });
element.addEventListener('drop', (e) => {
if (!this._dragEntry) return;
const li = e.target.closest && e.target.closest('.file-item');
this._clearDropTargets();
if (!li) return;
const target = this._byPath.get(li.dataset.path);
if (!target || target.entryType !== 'folder' || this._isInvalidDropTarget(this._dragEntry, target)) return;
e.preventDefault();
const copy = !!(e.ctrlKey || e.metaKey);
const dragged = this._dragEntry;
if (this.onDropIntoFolder) this.onDropIntoFolder(dragged, target, { copy });
}, { signal: this.signal });
element.addEventListener('dragend', () => {
this._clearDropTargets();
if (this._dragEntry && this.element) {
const dragLi = Array.from(this.element.children).find(
(li) => li.dataset && li.dataset.path === this._dragEntry.path,
);
if (dragLi) dragLi.classList.remove('is-dragging');
}
this._dragEntry = null;
}, { signal: this.signal });
// audit fix (R7a P1-15):windowed 模式 scroll 监听。
// passive: true 不阻塞滚动主线程;rAF 节流(_scheduleWindowedRender)保证
// 一帧最多一次 render,避免快速滚动时每个 scroll 事件都触发全池子 update。
element.addEventListener('scroll', () => this._scheduleWindowedRender(), {
signal: this.signal,
passive: true,
});
// dispose 时把挂起的 rAF 清掉,否则它会在组件已销毁后
// 触发 _renderWindowed 访问已被释放的 this.filteredFiles。
this.addAbort(() => {
if (this._scrollFrame) {
cancelAnimationFrame(this._scrollFrame);
this._scrollFrame = null;
}
});
}
if (searchInput) {
@@ -183,12 +305,55 @@ export class FileList {
return this._byPath.get(li.dataset.path) || null;
}
/**
* 清空所有 .is-drop-target 高亮(drop 目标指示)。
*/
_clearDropTargets() {
if (!this.element) return;
for (const li of this.element.children) {
if (li.classList && li.classList.contains('is-drop-target')) {
li.classList.remove('is-drop-target');
}
}
}
/**
* 安全设置 dataTransfer.dropEffect(部分浏览器 dataTransfer 受限时静默失败)。
* @param {DragEvent} e
* @param {'move'|'copy'|'none'} effect
*/
_setDropEffect(e, effect) {
try { e.dataTransfer.dropEffect = effect; } catch { /* ignore */ }
}
/**
* 判断 src 能否投放到 target(target 必须是 folder)。
* 非法情形:
* - 拖到自己(src.path === target.path)
* - 文件夹拖入自身或其子孙(循环 / 路径穿越)
* - 源已在 target 内部(同目录拖入 = 无意义的 name (2) 重名)
* @param {{path:string,entryType:string}} src
* @param {{path:string,entryType:string}} target
* @returns {boolean}
*/
_isInvalidDropTarget(src, target) {
if (!src || !target) return true;
if (src.path === target.path) return true;
// 跨平台分隔符(Windows \ 与 POSIX /)都覆盖
const selfDesc = src.entryType === 'folder'
&& (target.path === src.path + '/' || target.path === src.path + '\\'
|| target.path.startsWith(src.path + '/') || target.path.startsWith(src.path + '\\'));
const alreadyInside = target.path === src.path
|| target.path.startsWith(src.path + '/') || target.path.startsWith(src.path + '\\');
return !!(selfDesc || alreadyInside);
}
/**
* 设置排序模式(供设置对话框等调用方热切换)
* @param {'name' | 'mtime-desc'} mode
* @param {'name' | 'name-desc' | 'mtime-desc' | 'mtime-asc'} mode
*/
setSort(mode) {
const next = mode === 'mtime-desc' ? 'mtime-desc' : 'name';
const next = normalizeSortMode(mode);
if (next === this.sortMode) return;
this.sortMode = next;
this.applyFilter();
@@ -281,13 +446,27 @@ export class FileList {
// audit fix:Windows 路径大小写不敏感;state.currentFile.path 可能是用户输入
// 的 case,fs 返回的是磁盘真实 case。直接 === 比对会让侧栏永远找不到 active
// 条目、不显示高亮。统一走 pathsEqual(含 Windows case-fold)。
// audit fix (R7a P1-15):windowed 模式下 _sentinel 也是 element.children,
// 它的 dataset.path 是空字符串,与 activePath 永远不等,无副作用(不会变
// is-active),但白跑一遍 pathsEqual 不必要。显式 skip 节省一点点。
for (const li of this.element.children) {
if (li.classList.contains(SENTINEL_CLASS)) continue;
const active = pathsEqual(li.dataset.path, this.activePath);
li.classList.toggle('is-active', active);
li.setAttribute('aria-selected', active ? 'true' : 'false');
}
}
/**
* 返回当前高亮条目对应的 entry 对象(供键盘剪切 / 复制 / 粘贴定位源条目)。
* 没有高亮时返回 null。windowed 模式下 _byPath 仍含全部条目,查找不受影响。
* @returns {object|null}
*/
getActiveEntry() {
if (!this.activePath) return null;
return this._byPath.get(this.activePath) || null;
}
/**
* 列表获焦时由键盘导航调用:移动激活态到相邻项。只挪高亮 + 焦点,
* 不触发 onSelect —— 打开文件由 Enter / Space 键负责(走 _fileFromEvent)。
@@ -295,26 +474,40 @@ export class FileList {
* @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));
const total = this.filteredFiles.length;
if (total === 0) return;
// audit fix (R7a P1-15):windowed 模式下 DOM 里的 .file-item 只是当前
// 可见的窗口,activePath 对应的项可能不在 DOM 里。用 filteredFiles
// 反查 idx 而不是 querySelectorAll('.file-item')。
// audit fix:Windows 大小写不敏感走 pathsEqual。
let idx = this.activePath
? this.filteredFiles.findIndex(f => pathsEqual(f.path, this.activePath))
: -1;
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;
else if (delta === 'last') idx = total - 1;
else idx = Math.max(0, Math.min(total - 1, idx + delta));
const target = this.filteredFiles[idx];
this.activePath = target.path;
// 给当前 DOM 里的 .file-item(窗口化的池子)打高亮;sentinel 跳过。
// 旧的 forEach(items) 写法在 windowed 下不可靠 —— target 可能不在池里。
for (const li of this.element.children) {
if (li.classList.contains(SENTINEL_CLASS)) continue;
const active = pathsEqual(li.dataset.path, this.activePath);
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 */ }
}
// windowed 模式:把 target 滚到可视区(仅当 activePath 真正变化时调 scroll)
this._scrollIndexIntoView(idx);
// focus 当前 activePath 对应的 <li>(可能在池里、可能不在);
// 不在时 focus 落到 ul 容器上,键盘导航下次按键仍能 continue。
const targetLi = Array.from(this.element.children).find(li =>
!li.classList.contains(SENTINEL_CLASS) && pathsEqual(li.dataset.path, this.activePath));
if (targetLi) {
// audit fix (2.4):focus 默认会触发 scrollIntoView → 侧栏布局可能跳动。
// 改用 preventScroll,再用 _scrollIndexIntoView 把项拉到可视区但不强制居中。
try { targetLi.focus({ preventScroll: true }); } catch { /* ignore */ }
}
}
/**
@@ -342,24 +535,45 @@ export class FileList {
scored.sort((a, b) => b.score - a.score);
list = scored.map(s => s.file);
}
// 仅在无搜索时使用 sortMode;有搜索时 fuzzy 排序已覆盖
if (!term) {
if (this.sortMode === 'mtime-desc') {
// 有搜索时 fuzzy 排序已覆盖 sortMode,无需再切(避免重复扫描 + 误判)
if (term) {
this.filteredFiles = list;
return;
}
switch (this.sortMode) {
case 'mtime-desc':
list.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
} else {
break;
case 'mtime-asc':
list.sort((a, b) => (a.mtimeMs || 0) - (b.mtimeMs || 0));
break;
case 'name-desc':
// localeCompare(zh-CN) 反向:b 在前,a 在后
list.sort((a, b) => b.name.localeCompare(a.name, 'zh-CN'));
break;
case 'name':
default:
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
}
break;
}
this.filteredFiles = list;
}
/**
* 渲染 DOM
*
* audit fix (R7a P1-15):windowed 虚拟滚动分发。
* 10000 文件全量重建 ~200ms+ → 走 windowed 只渲 visibleCount+overscan 个,
* scroll 时复用 _pool 的 <li>,只 flush textContent + classList。
*
* 模式选择:
* - clientHeight === 0(jsdom 测试 / 隐藏的侧栏 / 测布局前)
* - 或 total ≤ visibleCount + 2*overscan(小列表)
* → 走 _renderFull,全量重建(保持 jsdom 既有 34 个测试直接通过)
* 其他情况 → 走 _renderWindowed,sentinel + pool + absolute 定位
*/
render() {
if (!this.element) return;
this.element.replaceChildren();
// 重建 path → file 索引,供委托的 click / keydown 反查
this._byPath.clear();
for (const file of this.filteredFiles) {
@@ -375,6 +589,8 @@ export class FileList {
const emptyMsg = this.searchTerm
? `没有匹配 “${this.searchTerm}” 的文件`
: '此文件夹为空';
this._teardownWindowed();
this.element.replaceChildren();
this.element.dataset.empty = emptyMsg;
return;
}
@@ -382,68 +598,128 @@ export class FileList {
// 有内容时清掉 data-empty,避免渲染期间被 :empty::after 短暂闪一下
this.element.removeAttribute('data-empty');
const term = this.searchTerm;
const total = this.filteredFiles.length;
const viewportH = this.element.clientHeight;
const visibleCount = viewportH > 0 ? Math.ceil(viewportH / FILE_ITEM_HEIGHT) : 0;
// 走 full 模式的两个条件(满足任一即 full):
// 1) clientHeight 0 → 没法算窗口,先 full 渲(jsdom / 隐藏侧栏)
// 2) 总数 ≤ 可见 + 2*overscan → 虚拟化没收益还多 sentinel 开销
if (viewportH === 0 || total <= visibleCount + FILE_ITEM_OVERSCAN * 2) {
if (this._windowed) this._teardownWindowed();
this._renderFull();
} else {
this._renderWindowed();
}
}
/**
* 全量渲染(兼容 jsdom / 小列表)
* 行为与 audit R7a P1-15 之前一致:replaceChildren + createElement + appendChild
*/
_renderFull() {
this.element.replaceChildren();
this._windowed = false;
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');
}
this._fillItem(li, file, term, false, 0);
frag.appendChild(li);
}
this.element.appendChild(frag);
}
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;
}
/**
* windowed 虚拟滚动渲染。
* - 用 _sentinel 撑出 total * FILE_ITEM_HEIGHT 的滚动高度
* - 用 _pool 维护 visibleCount + 2*overscan 个复用的 <li>
* - 每个 <li> position:absolute + top:N*50 钉在指定行
* - 滚动时只 flush textContent + classList,不重新创建 DOM
*/
_renderWindowed() {
if (!this._windowed) {
// 首次进入 windowed:清掉 full 模式的旧 <li>,建 sentinel
this.element.replaceChildren();
this._sentinel = document.createElement('li');
this._sentinel.className = SENTINEL_CLASS;
this._sentinel.setAttribute('aria-hidden', 'true');
this.element.appendChild(this._sentinel);
this._pool = [];
this._windowed = true;
}
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)}`;
}
const total = this.filteredFiles.length;
const viewportH = this.element.clientHeight;
const visibleCount = Math.ceil(viewportH / FILE_ITEM_HEIGHT);
const scrollTop = this.element.scrollTop;
// startIndex:scrollTop 落在哪一行。减 overscan 让上方提前几行开始出现,
// 跨边界滚动时不出现"滚到了但还有几行空白"的延迟感。
let startIndex = Math.floor(scrollTop / FILE_ITEM_HEIGHT) - FILE_ITEM_OVERSCAN;
if (startIndex < 0) startIndex = 0;
// endIndex:start + 可见 + 下方 overscan。封顶 total。
const endIndex = Math.min(total, startIndex + visibleCount + FILE_ITEM_OVERSCAN * 2);
const needed = endIndex - startIndex;
// 文件图标:左侧装饰,让 list 项视觉更有「文件感」。
// aria-hidden,因为纯视觉;语义信息已经在 file-name 里。
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
// sentinel 高度 = total * itemHeight,给滚动条撑出总高
this._sentinel.style.height = `${total * FILE_ITEM_HEIGHT}px`;
// 缩放 pool 到 needed:池子小了 append 新 li,池子大了 remove 末尾的
while (this._pool.length < needed) {
const li = document.createElement('li');
// 第一次建出来的 li 就要带 WINDOWED_CLASS(绝对定位),否则
// append 后到 _fillItem 设置 position 之间会先以 flex item 出现再跳位。
li.classList.add(WINDOWED_CLASS);
this.element.appendChild(li);
this._pool.push(li);
}
while (this._pool.length > needed) {
const li = this._pool.pop();
li.remove();
}
// 把数据填进 pool 的每个 li(复用:已有 li 只 flush textContent / classList)
const term = this.searchTerm;
for (let i = 0; i < needed; i++) {
const dataIndex = startIndex + i;
const file = this.filteredFiles[dataIndex];
const li = this._pool[i];
this._fillItem(li, file, term, true, dataIndex);
}
}
/**
* 把一个 file 对象的数据填进已有的 <li>。full 模式与 windowed 模式共用。
* 复用关键:只在数据真变化时写 DOM(classList / textContent / dataset)。
* @param {HTMLLIElement} li
* @param {object} file
* @param {string} term - 已 lowercase 的搜索词;空串 = 无搜索
* @param {boolean} isWindowed - 是否 windowed 模式(决定是否设 WINDOWED_CLASS + style.top)
* @param {number} dataIndex - 当前项在 filteredFiles 里的下标(windowed 用)
*/
_fillItem(li, file, term, isWindowed, dataIndex) {
// 基础属性:path / entryType / class(用 contains 避免重复 add)
if (li.dataset.path !== file.path) li.dataset.path = file.path;
if (li.dataset.entryType !== file.entryType) li.dataset.entryType = file.entryType;
// 所有条目可拖拽(文件 / 文件夹 / 二进制都可选中拖到其它文件夹)
if (li.draggable !== true) li.draggable = true;
const wantClass = `file-item is-${file.entryType}${isWindowed ? ` ${WINDOWED_CLASS}` : ''}`;
if (li.className !== wantClass) li.className = wantClass;
if (li.getAttribute('role') !== 'option') li.setAttribute('role', 'option');
if (!li.hasAttribute('tabindex')) li.tabIndex = 0;
// 激活态(Windows 大小写不敏感走 pathsEqual)
const isActive = pathsEqual(file.path, this.activePath);
li.classList.toggle('is-active', isActive);
li.setAttribute('aria-selected', isActive ? 'true' : 'false');
// 子节点:icon / name / meta。首次建后保留引用,scroll 时只改 textContent。
let name = li._cachedName;
let meta = li._cachedMeta;
let icon = li._cachedIcon;
if (!name) {
// 首次:建好结构并存到 ._cachedXxx。name/meta 是直接子 div,icon 是 svg
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');
@@ -454,29 +730,153 @@ export class FileList {
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';
name = document.createElement('div');
name.className = 'file-name';
meta = document.createElement('div');
meta.className = 'file-meta';
text.appendChild(name);
text.appendChild(meta);
li.appendChild(icon);
li.appendChild(text);
// 点击 / 键盘由构造函数里的委托监听统一处理,这里不再逐项绑定
frag.appendChild(li);
li._cachedName = name;
li._cachedMeta = meta;
li._cachedIcon = icon;
}
// 名称:搜索高亮(纯 textContent + <mark>,避免 innerHTML XSS)
if (term) {
const lowerName = file.name.toLowerCase();
const idx = lowerName.indexOf(term);
// 重渲时如果高亮结构变了(之前无 <mark> 现在有 / 反之),直接 replaceChildren
const existingMark = name.querySelector('mark');
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 (existingMark) {
// 复用:把结构稳定归一为 [beforeText, mark, afterText],否则搜索词在
// 开头/结尾时 childNodes 数量会变化,走 appendChild 会把旧名字的尾巴拼到新名字后
// (如 abc.md → 复用给 abc.txt 显示成 abc.md.txt)。before/after 文本节点可能为空。
let beforeNode = existingMark.previousSibling;
if (!beforeNode || beforeNode.nodeType !== Node.TEXT_NODE) {
beforeNode = document.createTextNode('');
name.insertBefore(beforeNode, existingMark);
}
let afterNode = existingMark.nextSibling;
if (!afterNode || afterNode.nodeType !== Node.TEXT_NODE) {
afterNode = document.createTextNode('');
name.appendChild(afterNode);
}
beforeNode.nodeValue = before;
existingMark.textContent = hit;
afterNode.nodeValue = after;
} else {
// 重建(之前无 mark)
name.replaceChildren();
if (before) name.appendChild(document.createTextNode(before));
const m = document.createElement('mark');
m.textContent = hit;
name.appendChild(m);
if (after) name.appendChild(document.createTextNode(after));
}
} else {
// 没命中 → 整个 name 设 textContent(无 mark)
if (existingMark) {
name.textContent = file.name;
} else if (name.textContent !== file.name) {
name.textContent = file.name;
}
}
} else {
// 无搜索:name 只在变化时写
if (name.textContent !== file.name) name.textContent = file.name;
}
// meta:folder/binary/editable 三种文案
let metaText;
if (file.entryType === 'folder') {
metaText = '文件夹';
} else if (file.entryType === 'binary') {
metaText = `${formatSize(file.size)} · 不可编辑`;
} else {
metaText = `${formatSize(file.size)} · ${formatRelativeTime(file.mtimeMs)}`;
}
if (meta.textContent !== metaText) meta.textContent = metaText;
// icon:entryType 不同用不同 SVG path。innerHTML 路径已硬编码且非用户输入,
// 这里继续走 innerHTML 比 createElement('path') + setAttribute 快 3-5x,
// windowed scroll 触发的 update 路径上值得这点性能。
let iconHtml;
if (file.entryType === 'folder') {
iconHtml = '<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') {
iconHtml = '<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 {
iconHtml = '<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"/>';
}
if (icon.innerHTML !== iconHtml) icon.innerHTML = iconHtml;
// windowed 专属:position:absolute + top:N*50(left/right 在 CSS 的 .is-windowed)
if (isWindowed) {
const top = dataIndex * FILE_ITEM_HEIGHT;
if (li.style.top !== `${top}px`) li.style.top = `${top}px`;
}
this.element.appendChild(frag);
}
/**
* 退出 windowed 模式:清掉 sentinel + pool,DOM 回到「无项目」状态
* (render 的 full 分支会自己 replaceChildren + append 新 li)
*/
_teardownWindowed() {
if (this._sentinel) {
this._sentinel.remove();
this._sentinel = null;
}
for (const li of this._pool) {
// 清掉 windowed 专属的 inline style,full 模式不需要 absolute 定位
li.style.top = '';
li.remove();
}
this._pool = [];
this._windowed = false;
}
/**
* scroll 事件 rAF 节流:一帧最多触发一次 _renderWindowed
* 快速滚动 wheel 事件 60+ 次/秒,不节流会卡
*/
_scheduleWindowedRender() {
if (this._scrollFrame) return;
if (!this._windowed) return; // full 模式不响应 scroll 事件
this._scrollFrame = requestAnimationFrame(() => {
this._scrollFrame = null;
this._renderWindowed();
});
}
/**
* 把第 idx 项滚到可见区域(仅 windowed 模式有效,full 模式 noop)
* moveActive 用:跨视口的键盘导航不依赖浏览器原生 focus
*/
_scrollIndexIntoView(idx) {
if (!this._windowed) return;
const top = idx * FILE_ITEM_HEIGHT;
const bottom = top + FILE_ITEM_HEIGHT;
const scrollTop = this.element.scrollTop;
const viewportH = this.element.clientHeight;
if (top < scrollTop) {
this.element.scrollTop = top;
} else if (bottom > scrollTop + viewportH) {
this.element.scrollTop = bottom - viewportH;
}
}
/** test hook:当前是否在 windowed 模式(单测断言) */
_test_isWindowed() { return this._windowed; }
/** test hook:当前 pool 大小 */
_test_poolSize() { return this._pool.length; }
/** test hook:当前 sentinel 是否存在 */
_test_hasSentinel() { return !!this._sentinel; }
}

View File

@@ -21,7 +21,7 @@
import { showToast } from './feedback.js';
import { showPromptDialog } from './prompt-dialog.js';
import { showConfirmDialog } from './confirm-dialog.js';
import { isMarkdownFile } from './utils.js';
import { isMarkdownFile, basenameOf } from './utils.js';
// 救援工具:与 src/app.js 共享同一份命名 / toast / friendlyFsError 逻辑,
// 这里只关心「我有 content、想救回」。详见 src/rescue.js 注释。
import { rescueDirtyContent } from './rescue.js';
@@ -72,10 +72,46 @@ function validateFileName(raw) {
return null;
}
/**
* 校验输入是否可作为文件夹名。镜像 main resolveFolderName 的输入合法性检查。
* 不校验「重名」—— 那是 IPC 端的事,folder:create 也走主进程协商,不在 renderer 重复实现。
*
* 与 validateFileName 保持一致:文件夹名**也做扩展名切割**,按基名判定保留设备名。
* Windows 对目录同样按基名保留设备名("CON.txt" / "AUX.abc" 都算保留,扩展名被忽略),
* 不切割会漏掉 "CON.txt" 这类非法名(客户端校验通过却最终在主进程 / 文件系统被拒)。
*
* 共享注释见上方 WIN_RESERVED_DEVICE_NAMES:
* main 是 CJS(require('fs')),renderer 是 ESM 不能 require Node 模块;
* 退而求其次把规则文档化在 main + renderer 两处注释,改动频次极低(Windows 保留设备名
* 清单基本不变),双拷贝维护成本可控。
*
* @param {string} raw
* @returns {string|null}
*/
function validateFolderName(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 对文件夹同样按「基名」判定保留设备名("CON.txt" / "AUX.abc" 都算保留),
// 与文件一致:取最后一个 . 之前的部分判断。不切割会漏掉 "CON.txt" 这类非法名。
const lastDotF = name.lastIndexOf('.');
const baseForReservedF = (lastDotF > 0 ? name.slice(0, lastDotF) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReservedF)) {
return `"${baseForReservedF}" 是 Windows 保留设备名,不允许作为文件夹名`;
}
return null;
}
/**
* @typedef {object} FileOpsCtx
* @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs)
* @property {object} controls - { fileList, viewer, editor, contextMenu }
* 注意(P2-9):editor 由 app.js bootstrap 延迟实例化,controls.editor 必须
* 经 getter 运行时现取(app.js 用对象字面量 getter 提供),本模块内部
* 一律走 getEditor(),不在构造时解构缓存。
* @property {object} api - preload contextBridge 暴露的 window.api
* @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? }
*/
@@ -88,9 +124,23 @@ export function createFileOps(ctx) {
if (!state || !controls || !api || !callbacks) {
throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks');
}
const { contextMenu, viewer, fileList, editor } = controls;
const { contextMenu, viewer, fileList } = controls;
// P2-9(audit C-4):app.js 顶层不再 new MarkdownEditor(CM6 dynamic import,
// bootstrap 里才实例化),createFileOps 在模块 eval 时 controls.editor 还是
// null —— 不能构造时解构缓存,必须运行时现取。app.js 侧 controls 用对象
// 字面量 getter 提供活视图(tests/unit/file-ops-lazy-editor.test.js 锁死)。
const getEditor = () => controls.editor;
const { openFile, updateWindowTitle, clearCurrentFile } = callbacks;
/**
* editor.getContent() 安全读取:editor 未实例化(null)或无该方法时回 ''。
* 仅 bootstrap 完成前的事件路径才可能命中 null 分支,正常恒非空。
*/
function _editorContentOrEmpty() {
const ed = getEditor();
return (ed && typeof ed.getContent === 'function') ? ed.getContent() : '';
}
/**
* 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名)。
*/
@@ -141,13 +191,13 @@ export function createFileOps(ctx) {
// 审计修复 (Round 11 deep-fix P1-1):syncDirtyFromEditor 把 editor.isDirty()
// 真相同步到 state.isDirty,避免 IME 合成期删除当前文件时跳过 dirty 确认
// 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。
syncDirtyFromEditor(state, editor);
syncDirtyFromEditor(state, getEditor());
const isCurrent = state.currentFile && state.currentFile.path === file.path;
// 删除当前文件 + 有未保存改动 → 二次确认
if (isCurrent && state.isDirty) {
const choice = await showConfirmDialog({
title: '删除未保存的文件',
message: `「${file.name}」有未保存改动,确定要删除(移至回收站)吗?`,
message: `「${file.name}」有未保存改动。删除(移至回收站)后无法恢复,确定继续?`,
icon: 'danger',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
@@ -162,26 +212,27 @@ export function createFileOps(ctx) {
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 返回后由本函数自己救一次,保证不漏。
// 文件/文件夹已不在了(用户手动删过、外部工具删过)。dirty 状态下不能直接
// clearCurrentFile 丢内容 —— 先救到 <base>-rescued-<stamp>.md,再清视图
// (与 renameCurrentOrFile 的 FILE_NOT_FOUND 路径对齐)。state.deletingPath
// 已设,挡住 onFilesChanged rescue 分支的并发救回,由本函数自己救一次。
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
// 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。
// 命名 / toast 文案 / friendlyFsError 错误码 vs message 的判断
// 全部收口到 src/rescue.js(之前这里手动传 r?.message || r?.error
// 是 audit Round 16 R2 修过的关键 bug —— 业务码不能给用户看)。
// 命名 / toast 文案 / 错误码 vs message 判断全部收口到 src/rescue.js。
rescueDirtyContent({
api,
originalName: state.currentFile.name,
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
content: _editorContentOrEmpty(),
event: '文件已不在',
});
} else {
showToast('文件已不存在', 'warning');
}
// 与 renameCurrentOrFile 对齐:dirty 救回 / 非脏提示后都清掉已删除文件的视图,
// 否则会留下一个指向已删除文件的空壳视图(之前漏了 clearCurrentFile)。
if (state.currentFile && state.currentFile.path === file.path) {
clearCurrentFile();
}
return;
} else {
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
@@ -214,10 +265,10 @@ export function createFileOps(ctx) {
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);
syncDirtyFromEditor(state, getEditor());
const raw = await showPromptDialog({
title: '重命名',
message: '重命名为',
title: '重命名文件',
message: '新文件名(可改为其他扩展名)',
defaultValue: file.name,
confirmLabel: '重命名',
validate: validateFileName,
@@ -259,7 +310,7 @@ export function createFileOps(ctx) {
rescueDirtyContent({
api,
originalName: state.currentFile.name,
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
content: _editorContentOrEmpty(),
event: '原文件已被删除',
});
} else {
@@ -309,9 +360,7 @@ export function createFileOps(ctx) {
// 再调 applyEditorMode 让工具栏按钮可用性 跟着对。
if (wasMd !== isMd) {
try {
const currentContent = editor && typeof editor.getContent === 'function'
? editor.getContent()
: '';
const currentContent = _editorContentOrEmpty();
if (isMd) {
viewer.loadContent(state.currentFile, currentContent);
} else {
@@ -353,31 +402,40 @@ export function createFileOps(ctx) {
}
/**
* 顶部「+」新建按钮:弹 prompt 取名 → 走 createFile → 自动打开新文件。
* 「新建笔记」入口:弹 prompt 取名 → 走 createFile → 自动打开新文件。
*
* Stage 8:把 state.currentDir 透传给 api.createFile,让新文件落在当前浏览的
* 子目录里(而不是永远落到数据根)。
* 2026-09 设计优化后,调用入口只剩两个:
* - 侧栏空白区域右键菜单的「新建文件」(src/app.js#showSidebarEmptyContextMenu)
* - 文件夹条目右键菜单的「在文件夹中新建文件」(showFileListContextMenu 的
* `new_file_in` 项,传 entry.path 作 targetDir)
* 侧栏顶部原「+」按钮已移除;旧的「顶部 + 按钮」注释保留会让后续维护者
* grep 不到调用点。Stage 8 把 state.currentDir 透传给 api.createFile,让新
* 文件落在当前浏览的子目录里(而不是永远落到数据根)。
*
* 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动
* 加上」—— 默认值已带 .md,用户可见 / 可改(直接改 .txt 等)。主进程
* resolveFileName 也对齐 resolveRenameName:用户输入什么就用什么,
* 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json
* 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。
*
* @param {string} [targetDir] 可选:在某个文件夹内新建时传入该文件夹绝对路径
* (侧栏右键文件夹 → 「在文件夹中新建文件」)。省略则落在当前浏览目录
* state.currentDir。prompt 文案据 targetDir 显示「当前文件夹:xxx」。
*/
async function createNewFile() {
async function createNewFile(targetDir) {
// 审计修复 (Round 11 deep-fix P1-1):entry sync 把 editor.isDirty() 真相同步给
// state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认,
// 用户的 CJK buffer 静默丢失。
syncDirtyFromEditor(state, editor);
syncDirtyFromEditor(state, getEditor());
if (state.isDirty) {
// 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动
const choice = await showConfirmDialog({
title: '新建文件',
message: '当前文件有未保存改动,新建文件将丢弃这些改动。继续?',
title: '新建笔记',
message: `「${state.currentFile?.name || '当前笔记'}」有未保存改动,新建后会被丢弃。`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '继续新建', variant: 'danger' },
{ value: 'confirm', label: '丢弃并新建', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
@@ -397,9 +455,11 @@ export function createFileOps(ctx) {
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 等)';
// targetDir 非空 ⇒ 在某个文件夹内新建,提示用该文件夹名而非当前浏览路径
const targetLabel = targetDir ? basenameOf(targetDir) : breadcrumbTail;
const message = targetLabel
? `新笔记的名称(含扩展名,默认 .md)\n当前文件夹:${targetLabel}`
: '新笔记的名称(含扩展名,默认 .md)';
const raw = await showPromptDialog({
title: '新建笔记',
message,
@@ -413,8 +473,10 @@ export function createFileOps(ctx) {
showToast('文件名不能为空', 'warning');
return;
}
// Stage 8:把当前目录绝对路径传给主进程;主进程负责 auto-mkdir(用户主动在该目录下新建)。
const result = await api.createFile(name, { dir: state.currentDir || '' });
// 落点:targetDir 优先(右键文件夹内新建),否则当前浏览目录 state.currentDir。
// 主进程负责 auto-mkdir(用户主动在该目录下新建)。
const dir = targetDir || state.currentDir || '';
const result = await api.createFile(name, { dir });
if (!result || !result.ok) {
showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
@@ -432,27 +494,325 @@ export function createFileOps(ctx) {
}
/**
* 文件列表右键菜单:重命名 / 在文件夹中显示 / 删除
* 新建文件夹:弹 prompt 取名 → 走 folder:create。
* 设计上**不自动进入**新建的空目录,保持当前视图不变(用户继续浏览原目录列表)。
* 自动进入会让上下文跳动,且 fs-watcher 会推「新增一条」让侧栏自动出现新条目。
*
* Stage 8:folder 条目上隐藏「重命名」—— file:rename 主进程逻辑强制 .md 后缀,
* 对目录并不合适;文件夹的批量整理通过 shell 文件管理器(系统级)操作更直接,
* 这里至少保留删除 + 在文件夹中显示。
* 落点:state.currentDir(当前浏览目录),与 createNewFile 同源语义。
* 默认值不带 .md / 任何后缀 —— 文件夹不要扩展名。
*
* @param {string} [targetDir] 可选:在某个文件夹内新建时传入该文件夹绝对路径
* (侧栏右键文件夹 → 「在文件夹中新建文件夹」)。省略则落在当前浏览目录。
*/
async function createNewFolder(targetDir) {
syncDirtyFromEditor(state, getEditor());
if (state.isDirty) {
const choice = await showConfirmDialog({
title: '新建文件夹',
message: `「${state.currentFile?.name || '当前笔记'}」有未保存改动,新建后会被丢弃。`,
icon: 'warning',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '丢弃并新建', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
// 与 createNewFile 同源:在 prompt 阶段保持 dirty 文件不变,成功建完才清。
// 若用户取消 prompt,编辑器内容原样保留。
}
const defaultName = `新建文件夹-${formatStampForName(new Date())}`;
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
? state.breadcrumb.map((s) => s.name).join('/')
: '';
// targetDir 非空 ⇒ 在某个文件夹内新建,提示用该文件夹名而非当前浏览路径
const targetLabel = targetDir ? basenameOf(targetDir) : breadcrumbTail;
const message = targetLabel
? `新文件夹的名称(不含扩展名)\n当前文件夹:${targetLabel}`
: '新文件夹的名称(不含扩展名)';
const raw = await showPromptDialog({
title: '新建文件夹',
message,
defaultValue: defaultName,
confirmLabel: '创建',
validate: validateFolderName,
});
if (raw == null) return;
const name = raw.trim();
if (!name) {
showToast('文件夹名不能为空', 'warning');
return;
}
const dir = targetDir || state.currentDir || '';
const result = await api.createFolder(name, { dir });
if (!result || !result.ok) {
showToast(`新建文件夹失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
// 推迟清空当前文件 —— 与 createNewFile 同源语义(用户已经同意丢弃 → 现在建成功再清)。
if (state.isDirty) clearCurrentFile();
showToast(`已创建文件夹:${result.name}`, 'success', 1500);
// 不自动进入新文件夹 —— fs-watcher 会把新条目推到侧栏,用户需要时再点。
}
/**
* 重命名文件夹:弹 prompt 取新名 → 走 folder:rename。
* 若被重命名的是当前目录或其祖先上的某一段,需要 callback 把
* state.currentDir / fs-watcher / breadcrumb 同步到新路径。
* 决策权交给 caller 的 `wasEntryAffectingCurrentDir` + `navigateToRelDir` 回调。
*/
async function renameFolder(entry) {
if (!entry || !entry.path || entry.entryType !== 'folder') return;
syncDirtyFromEditor(state, getEditor()); // 与 file rename 对齐:IME 合成期避免 stale state
const raw = await showPromptDialog({
title: '重命名文件夹',
message: '新文件夹名称(不含扩展名)',
defaultValue: entry.name,
confirmLabel: '重命名',
validate: validateFolderName,
});
if (raw == null) return;
const newName = raw.trim();
if (!newName) {
showToast('文件夹名不能为空', 'warning');
return;
}
if (newName === entry.name) return;
const result = await api.renameFolder(entry.path, newName);
if (!result || !result.ok) {
showToast(`重命名失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
showToast(`已重命名为:${result.name}`, 'success', 1500);
// 重命名影响当前路径:把 state.currentDir / currentRelDir / fs-watcher 同步过去
const { wasEntryAffectingCurrentDir, computeCurrentRelAfterRename, navigateToRelDir } = callbacks;
if (typeof wasEntryAffectingCurrentDir === 'function'
&& typeof computeCurrentRelAfterRename === 'function'
&& typeof navigateToRelDir === 'function') {
if (wasEntryAffectingCurrentDir(entry.path)) {
const newRel = computeCurrentRelAfterRename(entry.path, entry.name, result.path, result.name);
if (newRel != null) {
try {
await navigateToRelDir(newRel);
} catch (e) {
console.warn('[file-ops] rename 后续 navigateToRelDir 失败:', e);
}
}
}
}
}
/**
* 删除文件夹:弹 confirm → 走 file:delete(主进程已支持 folder 分支)。
* 用户反馈设计:"删除成功后将视图导到父级" —— 不因文件夹被删而失位。
*
* 复用 file:delete IPC 的 isFolder 分支:
* - 主进程调用 shell.trashItem(macOS / Windows 把整树挪到回收站)
* - 失败兜底 fs.rmdir(仅对空目录)
* - 返回 result.isFolder(本函数用不上 —— 不需要判断 file vs folder,调用方已知是 folder)
*
* 文件级 dirty 内容由 onFilesChanged 后续触发 rescue —— shell.trashItem 异步移走之后
* fs-watcher 会推「文件已不在」,state.currentFile 被清同时 dirty 内容被 rescue。
*/
async function deleteFolder(entry) {
if (!entry || !entry.path || entry.entryType !== 'folder') return;
syncDirtyFromEditor(state, getEditor());
const choice = await showConfirmDialog({
title: '删除文件夹',
message: `「${entry.name}」将被移至回收站(含其全部内容)。\n\n若其中有正在编辑的笔记,未保存内容会另存为「<基名>-rescued-<时间戳>.md」。`,
icon: 'danger',
buttons: [
{ value: 'cancel', label: '取消', variant: 'secondary' },
{ value: 'confirm', label: '删除', variant: 'danger' },
],
});
if (choice !== 'confirm') return;
// 记下删除前目录影响 —— 用 callback 判断被删的是否影响当前路径
const { wasEntryAffectingCurrentDir, parentRelForRemovedEntry, navigateToRelDir } = callbacks;
const affectCurrent = typeof wasEntryAffectingCurrentDir === 'function'
&& wasEntryAffectingCurrentDir(entry.path);
const result = await api.deleteFile(entry.path);
if (!result || !result.ok) {
if (result && result.error === 'FILE_NOT_FOUND') {
showToast('文件夹已不存在', 'warning');
} else {
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return;
}
}
showToast(`已移至回收站:${entry.name}`, 'success', 1500);
// 被删的是当前目录或其祖先 → 导航到被删 entry 的父级
if (affectCurrent && typeof navigateToRelDir === 'function') {
const parentRel = typeof parentRelForRemovedEntry === 'function'
? parentRelForRemovedEntry(entry.path, entry.name)
: '';
try {
await navigateToRelDir(parentRel);
} catch (e) {
console.warn('[file-ops] deleteFolder 后续 navigateToRelDir 失败:', e);
}
}
}
/**
* 把条目(文件 / 文件夹)移动到目标目录。
* 拖拽到文件夹、右键「剪切 → 粘贴」都走这里。
*
* 与 rename 的差异:move 不改扩展名、不动编辑器内容(只是换目录),
* 所以**不需要** syncDirtyFromEditor 的脏确认(用户当前的编辑内容仍留在编辑器里)。
*
* 若被移动的条目是当前打开的文件(或它的某个祖先文件夹里包含打开的文件):
* 更新 state.currentFile 的 path/name(mirror renameCurrentOrFile 的同步分支),
* 让侧栏高亮 / 状态栏 / 图片相对路径都跟过去。
*
* @param {{path:string,name:string,entryType?:string}} entry
* @param {string} destDir 目标目录绝对路径
*/
async function moveEntry(entry, destDir) {
if (!entry || !entry.path || !destDir) return null;
const result = await api.moveFile(entry.path, destDir);
if (!result || !result.ok) {
showToast(`移动失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return result || null;
}
showToast(`已移动到:${result.name}`, 'success', 1500);
// 移动涉及当前打开文件 → 同步路径 / 状态栏 / viewer 相对图解析
// 覆盖两种情况:
// 1) 移动的就是打开的文件本身(state.currentFile.path === entry.path)
// 2) 移动的是打开文件的祖先文件夹(state.currentFile.path 在 entry.path 之下)——
// audit 修复:之前只覆盖情况 1,导致情况 2 下 state.currentFileRelDir /
// currentFile.path 仍指向旧位置,onFilesChanged 把"旧目录的移除事件"
// 误判为"当前文件被删除",触发 rescue + 写一个孤儿 .md 到根目录。
if (state.currentFile && state.currentFile.path) {
const cfPath = state.currentFile.path;
if (cfPath === entry.path) {
_applyMoveToOpenFile(result);
} else if (result.entryType === 'folder' && cfPath.startsWith(entry.path)) {
// 祖先文件夹被移走 → 重新拼出文件新路径
const newFilePath = destDir + cfPath.slice(entry.path.length);
const newName = newFilePath.replace(/^.*[\\/]/, '');
_applyMoveToOpenFile({
path: newFilePath,
name: newName,
mtimeMs: state.currentFile.mtimeMs,
size: state.currentFile.size,
// destDir 的 relDir 与 result.relDir 一致(祖先移动后,新父 = destDir)
relDir: typeof result.relDir === 'string' ? result.relDir : '',
});
}
}
return result;
}
/**
* 把条目(文件 / 文件夹)复制到目标目录。
* 拖拽(按住 Ctrl/Cmd)/ 右键「复制 → 粘贴」走这里。
* 复制不影响当前打开的文件(源不变),无需同步 state.currentFile。
*
* @param {{path:string,name:string,entryType:string}} entry
* @param {string} destDir 目标目录绝对路径
*/
async function copyEntry(entry, destDir) {
if (!entry || !entry.path || !destDir) return null;
const result = await api.copyFile(entry.path, destDir);
if (!result || !result.ok) {
showToast(`复制失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
return result || null;
}
showToast(`已复制到:${result.name}`, 'success', 1500);
return result;
}
/**
* 被「移动」的当前打开文件:把磁盘新路径同步到 state / viewer / 侧栏高亮 / AI。
* move 不改变扩展名,所以不触发 内容重渲染(与 rename 带扩展名切换的路径不同)。
* 逻辑收口自 renameCurrentOrFile 的同步分支。
*/
function _applyMoveToOpenFile(result) {
// result 由 moveEntry 在 IPC 成功后传入,永不为 null(moveEntry 已有 !result.ok 守卫)。
const newMeta = {
path: result.path,
name: result.name,
mtimeMs: result.mtimeMs,
size: result.size,
};
state.currentFile = { ...state.currentFile, ...newMeta };
state.lastSavedMtimeMs = result.mtimeMs;
// 移动后文件所在目录变了:同步 currentFileRelDir(来自主进程 file:move 返回的
// POSIX relDir),否则 onFilesChanged 仍用旧目录比对,会把"旧目录的移除事件"
// 误判为"当前文件被删除"并清空编辑器(见 app.js onFilesChanged 的 payloadRel 比对)。
if (typeof result.relDir === 'string') {
state.currentFileRelDir = result.relDir;
}
if (fileList && typeof fileList.setActive === 'function') {
try { fileList.setActive(result.path); } catch { /* 不让 setActive 异常阻断流程 */ }
}
// viewer 用 currentFile.path 当相对图片 baseDir,路径变了必须重跑
viewer.setFileMeta(newMeta);
updateWindowTitle();
// 路径变化 → AI pendingDiff 的 baseContent / forFilePath 脱节 → 通知清盘
if (typeof callbacks.onFileChanged === 'function') {
try { callbacks.onFileChanged(state.currentFile); } catch (e) {
console.warn('[file-ops] move 通知 AI 失败:', e);
}
}
}
/**
* 文件列表右键菜单:
* 文件条目:剪切 / 复制 / 粘贴 / 重命名 / 在文件夹中显示 / 删除
* 文件夹条目:在文件夹中新建文件 / 在文件夹中新建文件夹 / (以上 + 文件条目全套)
*
* Stage 8:folder 条目也解锁「重命名」。
* 文件夹条目顶部多一组「在该文件夹内新建」,落点指向被右击的文件夹
* (右键文件夹 → 「在文件夹中新建文件」直接建进它里面);
* 侧栏空白右键的「新建」则落在当前浏览目录(见 src/app.js#showSidebarEmptyContextMenu)。
* 剪切 / 复制把条目记到 clipboard(state.clipboard,由上层回调维护);
* 粘贴仅在 clipboard 非空时可用(当前浏览目录为目标)。
*/
function showFileListContextMenu(entry, x, y) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) {
items.push({ label: '重命名', value: 'rename' });
// 文件夹条目:顶部加「在该文件夹内新建」一组,落点指向被右击的文件夹
// (与侧栏空白右键「新建」落当前目录相对,这里是「建进这个文件夹」)。
if (isFolder) {
items.push({ label: '在文件夹中新建文件', value: 'new_file_in' });
items.push({ label: '在文件夹中新建文件夹', value: 'new_folder_in' });
items.push({ separator: true, value: '' });
}
items.push({ label: '剪切', value: 'cut' });
items.push({ label: '复制', value: 'copy' });
items.push({ label: '粘贴', value: 'paste', disabled: !state.clipboard });
items.push({ separator: true, value: '' });
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);
if (value === 'new_file_in') {
createNewFile(entry.path);
} else if (value === 'new_folder_in') {
createNewFolder(entry.path);
} else if (value === 'cut') {
if (typeof callbacks.onCut === 'function') callbacks.onCut(entry);
} else if (value === 'copy') {
if (typeof callbacks.onCopy === 'function') callbacks.onCopy(entry);
} else if (value === 'paste') {
if (typeof callbacks.onPaste === 'function') callbacks.onPaste();
} else if (value === 'rename') {
if (isFolder) renameFolder(entry);
else renameCurrentOrFile(entry);
} else if (value === 'reveal') {
revealFile(entry);
} else if (value === 'delete') {
if (isFolder) deleteFolder(entry);
else deleteFile(entry);
}
},
});
}
@@ -460,9 +820,14 @@ export function createFileOps(ctx) {
return {
showFileListContextMenu,
createNewFile,
createNewFolder,
moveEntry,
copyEntry,
// 暴露给测试 / 调试用
renameCurrentOrFile,
renameFolder,
deleteFile,
deleteFolder,
revealFile,
formatStampForName,
};

63
src/fs-handler-lock.js Normal file
View File

@@ -0,0 +1,63 @@
// src/app.js onFilesChanged —— handler 并发闸(audit P1-4)
//
// 背景:原代码
// const myToken = ++fsHandlerToken;
// if (inFlightToken !== 0) { return; }
// inFlightToken = myToken;
// 自增和 SET 之间是 microtask 窗口:burst 50 events 时多个 callback 排在同一
// microtask queue,每个都看到 inFlightToken === 0,全部过闸 → handler
// 并发跑(applyEntries 双跑、scanDir 双发、UI 闪烁)。
//
// 修复:makeLock() 工厂 → { tryClaim, release, current }。tryClaim 是同步
// check-and-set 原子操作:锁空闲时 SET 并返新 token(非零),否则返 null。
// callback 入口 tryClaim → null 直接 return(pendingRescanRel 由调用方决定
// 是否记下)。
//
// 计数器:每次成功 tryClaim 自增,token 永不复用 —— 旧 handler 的 finally
// 即使「迟到的 release」也不会误释新 handler 的锁(release 检查 token 匹配)。
//
// ESM 导出(src/ 已是 ESM,跨 test 直接 import)。
/**
* 创建一个并发闸对象。
* @returns {{
* tryClaim: () => (number | null),
* release: (token: number) => boolean,
* current: () => number,
* }}
*/
export function makeLock() {
let inFlightToken = 0;
let nextToken = 0;
return {
/**
* 尝试占锁。锁空闲 → SET + 返新 token;锁被占 → 返 null。
* 同步原子,不留 microtask 窗口。
* @returns {number | null} 新 token(非零)或 null
*/
tryClaim() {
if (inFlightToken !== 0) return null;
nextToken += 1;
inFlightToken = nextToken;
return inFlightToken;
},
/**
* 释放锁。token 与当前锁不匹配 → 返 false,锁不变(防止旧 handler 误释
* 新 handler 的锁,例如「finally 迟到」场景)。
* @param {number} token tryClaim 返回的 token
* @returns {boolean} true = 已释放,false = token 不匹配
*/
release(token) {
if (inFlightToken !== token) return false;
inFlightToken = 0;
return true;
},
/**
* 当前锁的 token。0 = 空闲。
* @returns {number}
*/
current() {
return inFlightToken;
},
};
}

View File

@@ -9,6 +9,7 @@
import { EditorState, Prec, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { history } from '@codemirror/commands';
import { markdown } from '@codemirror/lang-markdown';
import { getTextStats } from './stats.js';
import { pickEditorTheme } from './editor-theme.js';
@@ -103,7 +104,16 @@ export class MarkdownEditor {
}
/**
* 加载文件内容到编辑器。多次调用会销毁旧 view。
* 加载文件内容到编辑器。多次调用复用同一 view(不再 _destroyView + new)。
*
* audit fix (R7b P1-18):单实例 `EditorView` 持久化。
* - 旧实现每次 loadFile 都 _destroyView() + new EditorView() → history /
* selection / 折叠全丢 + 30-100ms 黑闪 + 重新挂 N 个 IME listener
* (极端时序 compositionend 跑多次 → state.isDirty 翻脏态反复)。
* - 新实现:view 已存在 → dispatch 替换 doc + selection reset 到开头
* (用户「打开文件」是导航动作,光标落文首是符合直觉的默认);
* view 不存在 → 走 _createView 一次性建好(首次 loadFile 触发)。
*
* @param {{path:string,name:string}} file
* @param {string} content
*/
@@ -114,27 +124,56 @@ export class MarkdownEditor {
if (!this.element || typeof this.element.appendChild !== 'function') {
throw new Error('MarkdownEditor: element required (must be a DOM node)');
}
this._destroyView();
// Round 13 fix (H8):clearListeners 用新 AbortController 包住本轮 view 的
// listener 与 IME handler。下次 loadFile() / dispose() 时清掉旧 listener。
// 旧实现每次 loadFile 都给 contentDOM addEventListener,但只 dispose 时
// 才走 addAbort 的 remove —— 多次开文件会让 DOM 上累积多份 IME handler,
// 极端时序(compositionend 在 view 已 destroy 之后才 fire)会跑多次
// onChangeCallback,让 app 翻脏态 + 调度多余的 auto-save。
this._viewAbort = new AbortController();
// 把 contentDOM 上的 listener 挂到这个 signal,loadFile 重入 / dispose
// 都一次性 abort。CM6 自己的 listener 用 contentDOM 默认挂(不在 signal
// 上),由 _destroyView / view.destroy 拆解。
const contentSignal = this._viewAbort.signal;
this.lastSavedDoc = content || '';
const insert = content || '';
if (this.view) {
// view 已存在 → 复用。dispatch 替换 doc,selection 故意重置到 (0,0)
// (用户主动打开文件 = 导航,光标落文首是合理默认;与 setExternalContent
// 的「保留光标位置」差异是设计意图:setExternalContent 是外部覆盖,
// 不该打断用户当前阅读位置;loadFile 是用户主动切文件,重置是合理的)。
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert },
selection: { anchor: 0, head: 0 },
});
return;
}
// 首次:建 view(之后不再重建,直到 dispose / unload)
this._createView(insert);
}
/**
* 首次创建 EditorView + IME listener。view 整个 lifetime 只调一次。
* loadFile 在 view 已存在时不会重入这里。
*
* @param {string} initialContent - 初始 doc 内容
*/
_createView(initialContent) {
// Round 13 fix (H8):clearListeners 用新 AbortController 包住本轮 view 的
// listener 与 IME handler。下次 dispose() 时清掉旧 listener。view 在
// MarkdownEditor 生命周期内只建一次,所以这个 signal 也只创一次。
this._viewAbort = new AbortController();
// 把 contentDOM 上的 listener 挂到这个 signal,dispose 时整体 abort。
// CM6 自己的 listener 用 contentDOM 默认挂(不在 signal 上),由 view.destroy
// 拆解。
const contentSignal = this._viewAbort.signal;
const state = EditorState.create({
doc: this.lastSavedDoc,
doc: initialContent,
extensions: [
EditorView.lineWrapping,
basicSetup,
// audit P0-3(C-1):basicSetup 默认带 history() 但 history 大小无显式
// 上限(默认 minDepth=100,updateBranch 实际有 +20 buffer,所以 120)。
// 打开 5MB 文件 + 长时间编辑 → history 数组几十~几百 MB,OOM 风险。
// 覆盖配置:minDepth=500 限制 history 长度(老 step 被丢弃,updateBranch
// 内部 +20 buffer,所以 520 上限),newGroupDelay=200ms 把短时间内的连续
// keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千条 history)。
// 注:CM6 API 用 minDepth(虽然语义上是 max),不是 audit 文档误写的
// maxDepth;参数名以 @codemirror/commands 的 HistoryConfig 为准。
Prec.high(history({ minDepth: 500, newGroupDelay: 200 })),
markdown(),
this.themeCompartment.of(pickEditorTheme(this.currentTheme)),
this.editableCompartment.of(EditorView.editable.of(true)),

View File

@@ -17,6 +17,13 @@ function cssAttrEscape(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* 状态栏路径在「无文件 / 无目录信息」时的占位文字。
* 提取常量是为了 clear() / refreshCurrentDir() 里的 aria-label 判定
* 不再硬编码 '—' 字面量,将来换占位也不会静默错分支。
*/
const STATUS_PATH_PLACEHOLDER = '—';
/**
* 渲染 markdown 字符串为安全的 HTML
* - 委托给 utils.safeRenderMarkdown:marked + DOMPurify 实际初始化在 preload.js 里完成
@@ -27,6 +34,13 @@ function renderMarkdown(markdown) {
return safeRenderMarkdown(markdown);
}
/** 代码块复制按钮图标(lucide copy) */
const ICON_COPY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
/** 复制成功对勾(lucide check) */
const ICON_CHECK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
/** lightbox 关闭(×) */
const ICON_CLOSE = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
export class MarkdownViewer {
/**
* @param {object} options
@@ -60,8 +74,12 @@ export class MarkdownViewer {
// 由 _rebuildHeadingObserver 同步构建;状态栏 heading chip 用它把
// currentHeadingId 还原成可读文本。
this._headingMap = new Map();
// IntersectionObserver:跟踪"当前在视口顶部的 heading"
// IntersectionObserver:跟踪"当前在视口顶部的 heading"。
// P2-16:实例持久化(只建一次);_headingNodes / _headingVisibleIds 是
// 它的回调读取的实例状态,每次 _rebuildHeadingObserver 全量替换。
this._headingObserver = null;
this._headingNodes = [];
this._headingVisibleIds = null;
// 每个打开过的文件保留一份滚动位置。
// 上限 SCROLL_POSITION_CACHE_LIMIT = 200:超出后按插入顺序淘汰最旧未访问的条目,
// 防止打开几千个文件后 Map 无限增长(每个 entry ≈ 8 字节 key + 8 字节 number)。
@@ -75,6 +93,13 @@ export class MarkdownViewer {
// 关键 await 之后再比一次 —— 不等就 return,避免 A 的解析污染 B 的图片。
this._resolveGen = 0;
// audit R7b P1-16:loadContentLive 的 rAF chunked insert 单飞行控制。
// 新一次 loadContentLive / loadContent / clear / showUnsupportedFile / dispose
// 都必须 abort 上一次还没跑完的分批插入,否则旧 chunk 会 append 进新 DOM。
this._liveInsertAbort = null;
// 测试钩子用:_applyHtmlLive 返回的 promise(已 .catch 兜底,不会 reject)。
this._liveInsertPromise = null;
// 缓存空态元素初始 innerHTML —— showUnsupportedFile 会临时改写它,
// 下一次 loadContent/clear 再还原回 index.html 里的默认文案(避免
// 「上次打开 .txt 残留的'不支持预览'文字」污染下次空态)。
@@ -89,6 +114,9 @@ export class MarkdownViewer {
// 替换后旧回调还能触发,让新组件的 state 被污染。abort 时一次性清空。
this.addAbort(() => {
this.onHeadingChangeCallbacks.clear();
// audit R7b P1-16:dispose 时取消还没跑完的 chunked insert,
// 避免后续 rAF 帧继续往已废弃的 bodyElement 里 append。
this._cancelLiveInsert();
// audit fix (Round 12 P3):_headingObserver 是 IntersectionObserver,
// _rebuildHeadingObserver / clear / showUnsupportedFile 三处各自
// disconnect,但 dispose 路径没有兜底。detach 后的 heading 节点
@@ -98,6 +126,20 @@ export class MarkdownViewer {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
// P2-16:持久化 observer 的实例状态也一并释放(节点数组 / 可见集),
// 防止 dispose 后仍持有 detached heading 节点引用。
this._headingNodes = [];
this._headingVisibleIds = null;
// 清理交互增强产生的全局浮层(FAB / lightbox),避免 dispose 后残留
if (this._copyFab && this._copyFab.parentNode) {
this._copyFab.parentNode.removeChild(this._copyFab);
this._copyFab = null;
}
window.clearTimeout(this._copyFabReset);
if (this._lightboxOverlay && this._lightboxOverlay.parentNode) {
this._lightboxOverlay.parentNode.removeChild(this._lightboxOverlay);
this._lightboxOverlay = null;
}
});
// 拦截链接点击:所有 .markdown-body 内的 <a> 都通过 onExternalLink 走系统默认浏览器
@@ -137,6 +179,10 @@ export class MarkdownViewer {
}
}, { signal: this.signal });
}
// 阅读区交互增强:代码块复制 + 图片放大。刻意不改动 <pre>/<img> 的 DOM,
// 以免破坏 loadContentLive 的 isEqualNode 前缀/后缀 diff(见 [[feedback-live-diff-safe]])。
this._setupContentInteractions();
}
/**
@@ -168,6 +214,10 @@ export class MarkdownViewer {
async loadContent(file, content) {
if (!file || !this.bodyElement) return;
// audit R7b P1-16:切文件必须先取消还在跑的 live chunked insert,
// 否则旧文件的剩余 chunk 会 append 进新文件的 DOM。
this._cancelLiveInsert();
// 恢复空态为 index.html 里的默认文案(showUnsupportedFile 可能改写过了)
this._resetEmptyState();
@@ -257,6 +307,8 @@ export class MarkdownViewer {
*/
showUnsupportedFile(file) {
if (!file) return;
// audit R7b P1-16:清空正文前取消 in-flight live chunked insert
this._cancelLiveInsert();
this.currentFile = file;
this.currentContent = '';
this.currentHeadingId = null;
@@ -318,8 +370,13 @@ export class MarkdownViewer {
* `() => window.api.getDataDir()`),取不到时退化为「只挡 ../.. 越界字符串」的旧行为。
*
* @param {string} filePath - 当前 markdown 文件的绝对路径
* @param {Node[]} [roots] - audit P2-14:只扫这些顶级块内的 img(live diff
* 路径传入本次新插入的块)。未变更块的 img 上一轮已解析成 file://
* (绝对 src 在循环里本来就会跳过),scope 后连 querySelectorAll +
* getAttribute 的 O(N) 遍历都省掉。不传 → 全 body 扫描(loadContent /
* setFileMeta 路径维持原语义)。传 [] → 零变更,直接不扫。
*/
async resolveRelativeImages(filePath) {
async resolveRelativeImages(filePath, roots) {
if (!this.bodyElement || !filePath) return;
// audit fix (perf-MEDIUM):A→B 快速切文件时,旧 loadContent 启动的
// resolveRelativeImages(A) 是 fire-and-forget;await getDataDir 期间
@@ -352,7 +409,21 @@ export class MarkdownViewer {
// await 之后再判一次 —— 期间可能被新 loadContent / loadContentLive 接管
if (myGen !== this._resolveGen) return;
for (const img of this.bodyElement.querySelectorAll('img[src]')) {
// audit P2-14:roots scope。块自身是 <img>(顶级即图片)或块内嵌套
// <img>(<p><img></p> 等)两种情况都要覆盖。
let imgs;
if (Array.isArray(roots)) {
imgs = [];
for (const root of roots) {
if (!root || root.nodeType !== 1) continue;
if (root.tagName === 'IMG' && root.hasAttribute('src')) imgs.push(root);
for (const img of root.querySelectorAll('img[src]')) imgs.push(img);
}
} else {
imgs = this.bodyElement.querySelectorAll('img[src]');
}
for (const img of imgs) {
const src = img.getAttribute('src');
if (!src) continue;
// 带协议(含 data:/file:/http:)或以 / 开头的绝对路径 → 原样保留
@@ -433,6 +504,8 @@ export class MarkdownViewer {
clear() {
// 恢复空态默认文案(showUnsupportedFile 可能改写过了)
this._resetEmptyState();
// audit R7b P1-16:清空正文前取消 in-flight live chunked insert
this._cancelLiveInsert();
if (this.bodyElement) {
this.bodyElement.innerHTML = '';
@@ -449,7 +522,14 @@ export class MarkdownViewer {
}
// 只删当前文件的滚动位置 —— loadContent 已经在切换前把 top 存进 scrollPositions,
// 再清一次反而覆盖。clear 的语义就是「不再有 currentFile」,所以 entry 留着无害。
if (this.statusPath) this.statusPath.textContent = this._folderLabel();
if (this.statusPath) {
const label = this._folderLabel();
this.statusPath.textContent = label;
// audit P2-19:aria-label 与 textContent 同步,让屏幕阅读器既知道按钮作用
// 也知道当前是哪个文件 / 文件夹。无文件时退到仅描述行为。
this.statusPath.setAttribute('aria-label',
label === STATUS_PATH_PLACEHOLDER ? '在文件管理器中显示当前文件夹' : `在文件管理器中显示 ${label}`);
}
if (this.statusMeta) this.statusMeta.textContent = '';
this._emitHeadingChange();
}
@@ -462,13 +542,17 @@ export class MarkdownViewer {
refreshCurrentDir() {
if (!this.statusPath) return;
if (this.currentFile) return; // 文件路径优先,不覆盖
this.statusPath.textContent = this._folderLabel();
const label = this._folderLabel();
this.statusPath.textContent = label;
// 与 clear() 同步:路径刷新后 aria-label 跟着变(audit P2-19)。
this.statusPath.setAttribute('aria-label',
label === STATUS_PATH_PLACEHOLDER ? '在文件管理器中显示当前文件夹' : `在文件管理器中显示 ${label}`);
}
/** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到 '—'。 */
/** 内部:根据 getCurrentDir 算出无文件时该显示的路径;拿不到则退到「—」占位。 */
_folderLabel() {
const dir = this.getCurrentDir ? this.getCurrentDir() : '';
return dir || '—';
return dir || STATUS_PATH_PLACEHOLDER;
}
/**
@@ -476,7 +560,15 @@ export class MarkdownViewer {
*/
updateStatusBar(file) {
if (this.statusPath) {
this.statusPath.textContent = file.path || file.name;
const path = file.path || file.name || STATUS_PATH_PLACEHOLDER;
this.statusPath.textContent = path;
// audit P2-19:aria-label 与 textContent 同源(统一用 path),屏幕阅读器
// 听到什么、看到什么就一致。原本用 file.name 时,对深层路径 SR 只听到短名、
// 视觉用户看到全路径,两边漂移。
this.statusPath.setAttribute('aria-label',
path === STATUS_PATH_PLACEHOLDER
? '在文件管理器中显示当前文件夹'
: `在文件管理器中显示 ${path}`);
}
if (this.statusMeta) {
const parts = [];
@@ -551,6 +643,179 @@ export class MarkdownViewer {
return () => scroller.removeEventListener('scroll', handler);
}
/**
* 设置阅读区「微交互」:代码块复制按钮 + 图片点击放大。
*
* 设计约束:loadContentLive 用 isEqualNode 做前缀/后缀 diff,任何对 <pre>/<img>
* 子节点的 DOM 注入都会在实时编辑时破坏匹配、造成闪烁。所以这里的两类增强都
* 走「事件委托 + 全局浮层」,绝不往 pre/img 里塞节点:
* - 复制按钮 = 一个固定定位的浮动 FAB(document.body 上常驻一个),
* 悬浮代码块时算出位置贴到右上角;点击时读取 pre 文本写入剪贴板。
* - 图片放大 = 一个常驻的全局 lightbox 浮层(#image-lightbox),
* 点击正文 <img> 时填入 src/alt 并显示。
* 所有监听挂在 this.signal 上,dispose 自动解绑;FAB / lightbox 浮层在
* 构造函数 addAbort 中一并从 document 移除。
*/
_setupContentInteractions() {
if (!this.bodyElement) return;
const body = this.bodyElement;
let copyFab = null;
let hoverPre = null;
const ensureCopyFab = () => {
if (copyFab) return copyFab;
copyFab = document.createElement('button');
copyFab.type = 'button';
copyFab.className = 'code-copy-fab';
copyFab.setAttribute('aria-label', '复制代码');
copyFab.hidden = true;
copyFab.innerHTML = ICON_COPY;
copyFab.addEventListener('click', (e) => {
e.stopPropagation();
if (!hoverPre) return;
const codeEl = hoverPre.querySelector('code');
const text = (codeEl ? codeEl.innerText : hoverPre.innerText) || '';
this._copyText(text).then((ok) => {
if (!ok) return;
copyFab.classList.add('is-copied');
copyFab.innerHTML = ICON_CHECK;
window.clearTimeout(this._copyFabReset);
this._copyFabReset = window.setTimeout(() => {
copyFab.classList.remove('is-copied');
copyFab.innerHTML = ICON_COPY;
}, 1200);
});
});
copyFab.addEventListener('mouseleave', (e) => {
// 从 FAB 移回 pre 时不收起,避免点击前按钮消失
if (e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('pre')) return;
copyFab.hidden = true;
hoverPre = null;
});
document.body.appendChild(copyFab);
this._copyFab = copyFab;
return copyFab;
};
const positionFab = (pre) => {
const fab = ensureCopyFab();
const rect = pre.getBoundingClientRect();
// FAB 默认隐藏(offsetWidth=0),直接用 CSS 约定的 28px 宽度算位置,避免测不到
const fabW = 28;
if (rect.bottom < 0 || rect.top > window.innerHeight) {
fab.hidden = true;
return;
}
let left = rect.right - fabW - 8;
let top = rect.top + 8;
left = Math.max(8, Math.min(left, window.innerWidth - fabW - 8));
top = Math.max(8, top);
fab.style.left = `${left}px`;
fab.style.top = `${top}px`;
fab.hidden = false;
};
body.addEventListener('mouseover', (e) => {
const pre = e.target.closest && e.target.closest('pre');
if (!pre) return;
positionFab(pre);
hoverPre = pre;
}, { signal: this.signal });
body.addEventListener('mouseout', (e) => {
const pre = e.target.closest && e.target.closest('pre');
if (!pre) return;
// 移到 FAB(在 bodyElement 之外)或 pre 内部子元素时不收起
if (e.relatedTarget === copyFab) return;
if (e.relatedTarget && pre.contains(e.relatedTarget)) return;
if (hoverPre === pre) hoverPre = null;
if (copyFab) copyFab.hidden = true;
}, { signal: this.signal });
const scroller = body.parentElement;
if (scroller) {
scroller.addEventListener('scroll', () => {
if (hoverPre && hoverPre.isConnected) positionFab(hoverPre);
else if (copyFab) copyFab.hidden = true;
}, { passive: true, signal: this.signal });
}
// 图片点击放大(事件委托,不改动 <img>)
body.addEventListener('click', (e) => {
const img = e.target.closest && e.target.closest('img');
if (!img) return;
this._openLightbox(img.currentSrc || img.src, img.alt || '');
}, { signal: this.signal });
}
/** 打开图片 lightbox(常驻全局浮层,避免重复创建 / 破坏 live diff)。 */
_openLightbox(src, alt) {
let overlay = this._lightboxOverlay;
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'image-lightbox';
overlay.className = 'image-lightbox';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', '图片预览');
overlay.hidden = true;
overlay.innerHTML = `
<div class="image-lightbox-backdrop" data-close="1"></div>
<div class="image-lightbox-stage">
<img class="image-lightbox-img" alt="">
<button type="button" class="image-lightbox-close" data-close="1" aria-label="关闭 (Esc)">${ICON_CLOSE}</button>
</div>`;
overlay.addEventListener('click', (e) => {
if (e.target.dataset && e.target.dataset.close) this._closeLightbox();
});
document.body.appendChild(overlay);
this._lightboxOverlay = overlay;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay && !overlay.hidden) this._closeLightbox();
}, { signal: this.signal });
}
const img = overlay.querySelector('.image-lightbox-img');
img.src = src;
img.alt = alt;
overlay.hidden = false;
const closeBtn = overlay.querySelector('.image-lightbox-close');
if (closeBtn) closeBtn.focus();
}
/** 关闭图片 lightbox。 */
_closeLightbox() {
const overlay = this._lightboxOverlay;
if (!overlay) return;
overlay.hidden = true;
const img = overlay.querySelector('.image-lightbox-img');
if (img) img.src = '';
}
/** 写入剪贴板,Electron 环境下 clipboard API 可能不可用,用 textarea execCommand 兜底。 */
async _copyText(text) {
if (!text) return false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* 走兜底 */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
/**
* 轻量刷新(仅替换 body HTML,不动滚动位置 / 不触发 status bar 更新 / 不存历史)。
*
@@ -584,20 +849,190 @@ export class MarkdownViewer {
// 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();
// audit R7b P1-16 + R8 P2-17:不再同步 `bodyElement.innerHTML = html` 整页替换。
// 5MB markdown 渲染后 ~7-10MB HTML,同步解析 + 布局一次吃掉 100-400ms
// 主线程,200ms debounce 的实时预览路径打字明显掉帧。_applyHtmlLive 做
// 两端 diff:未变更的顶级块保留原 DOM 节点(heading observer 的观察
// 目标随之存活),只删 + 插真正变化的中段;大中段再按每帧 ≤ 200 个
// 节点分批,把成本摊到多帧。
//
// 单飞行:新一次 loadContentLive 先 abort 上一次还没跑完的 apply。
// 中途 abort 留下的「新旧混合」body 会被下一次 diff 自动收敛(diff 的
// 旧侧总是读当前真实 DOM),不需要清空重来。
this._cancelLiveInsert();
const controller = typeof AbortController === 'function' ? new AbortController() : null;
this._liveInsertAbort = controller;
this._liveInsertPromise = this._applyHtmlLive(html, controller ? controller.signal : null)
.catch((e) => {
// apply 内部不抛(每步都有 signal 检查),这里是防御性兜底,
// 与 loadContent 的 resolveRelativeImages catch 对齐。
console.warn('[viewer] live apply 失败:', e && e.message);
});
return { changed: true };
}
/**
* 取消还没跑完的 live apply(audit R7b P1-16 / R8 P2-17)。
*
* 所有「替换 / 清空 bodyElement」的入口都要先调它:loadContent /
* loadContentLive(下一次)/ clear / showUnsupportedFile / dispose。
* 否则旧 apply 的后续 rAF 帧会继续 insertBefore,污染新 DOM。
*/
_cancelLiveInsert() {
if (this._liveInsertAbort) {
try { this._liveInsertAbort.abort(); } catch { /* ignore */ }
this._liveInsertAbort = null;
}
}
/**
* 把 HTML 字符串增量应用到 bodyElement(audit R7b P1-16 + R8 P2-17)。
*
* 流程:
* 1. (同步,大 html 推迟一帧)createContextualFragment 解析 html
* —— fragment 节点全部 detached,解析不触发 body 的重排
* 2. 两端 diff:旧 body.childNodes vs 新 fragment.childNodes,
* 共同前缀 / 后缀用 isEqualNode 判定保留 —— 节点引用不动,
* heading observer 的观察目标随之存活。
* 已知例外:含已解析图片的块无法保留 —— resolveRelativeImages 把
* src 改写成 file:// 并加 loading/decoding,与下一轮渲染产物里的
* 原始相对 src 不再 isEqualNode → 该块被替换 + scoped 重解析。
* 与 P1-16 前的全量替换基线等价(图片块本来就每轮重解析),非回归。
* 3. 只删「旧中段」、只插「新中段」:实时打字场景中段通常就是
* 正在编辑的那一个块 → 每次 debounce 只动 1 个节点
* 4. 新中段 ≤ CHUNK 同步插(小文档零额外延迟);> CHUNK 每帧最多
* CHUNK 个节点分批,每帧开头检查 signal.aborted
* 5. 全部落地 → _finishLiveInsert:重建 heading observer + 按变更块
* scope 启动 resolveRelativeImages。两者都必须看到完整 DOM 才有意义。
*
* 两端 diff 的适用性:每次 debounce tick 对应源文档一段连续编辑,
* 渲染产物的差异也是一段连续区间(单块改 / 单块增删最常见),
* 前缀+后缀双侧收缩正好覆盖。退化场景(AI apply-all / 外部全量改写)
* 中段 = 全文,行为等价于 P1-16 的分批全量替换, correctness 不变。
*
* abort 自愈:中途 abort 不做任何回滚,body 停在「新旧混合」态;
* 下一次 _applyHtmlLive 的旧侧读当前真实 DOM,diff 自动补齐差异。
*
* @param {string} html - renderMarkdown 的产物
* @param {AbortSignal|null} signal - 取消信号;null 时退化为不检查(兼容老环境)
*/
async _applyHtmlLive(html, signal) {
const body = this.bodyElement;
if (!body) return;
if (signal && signal.aborted) return;
const source = html || '';
// 大文档(> 256KB)连解析都推迟到下一帧:createContextualFragment 对
// 7-10MB HTML 的解析本身就要 50-150ms,放在同步段会让 loadContentLive
// 的调用方(editor onChange → debounce 回调)依然卡一个大帧。
// 小文档保持同步解析 —— 典型笔记(几 KB)零额外延迟。
const LARGE_HTML_BYTES = 256 * 1024;
if (source.length > LARGE_HTML_BYTES) {
await this._nextFrame();
if (signal && signal.aborted) return;
}
// createContextualFragment 走浏览器原生 HTML parser,产物是 detached
// fragment,解析期间不触发 body 的 layout/paint。
const range = document.createRange();
const fragment = range.createContextualFragment(source);
// 两端 diff。isEqualNode 是原生深比较(属性顺序不敏感、无字符串分配),
// 未变更块 O(块大小) 但全程 native code,远快于重建 DOM。
const oldNodes = Array.from(body.childNodes);
const newNodes = Array.from(fragment.childNodes);
let start = 0;
let oldEnd = oldNodes.length - 1;
let newEnd = newNodes.length - 1;
while (start <= oldEnd && start <= newEnd && oldNodes[start].isEqualNode(newNodes[start])) start++;
while (oldEnd >= start && newEnd >= start && oldNodes[oldEnd].isEqualNode(newNodes[newEnd])) {
oldEnd--;
newEnd--;
}
const removeCount = oldEnd - start + 1;
const insertCount = newEnd - start + 1;
if (removeCount <= 0 && insertCount <= 0) {
// 完全等价(debounce 抖动 / 属性顺序差异 / 改了又改回)→ 零 DOM 变更。
// 仍走 finish:heading observer reconcile + 图片解析保持幂等语义。
this._finishLiveInsert(signal, []);
return;
}
// 删旧中段(detach 便宜,同步做)。
for (let i = start; i <= oldEnd; i++) {
body.removeChild(oldNodes[i]);
}
if (signal && signal.aborted) return;
// 插新中段:refNode 是旧 suffix 第一个保留节点(仍在 body 里),
// 没有 suffix 时 insertBefore(n, null) 等价 appendChild。
const refNode = oldNodes[oldEnd + 1] || null;
const toInsert = newNodes.slice(start, newEnd + 1);
const CHUNK = 200; // 每帧 ≤ 200 个节点(P1-16 与用户对齐的取值)
if (toInsert.length <= CHUNK) {
for (const n of toInsert) body.insertBefore(n, refNode);
} else {
let idx = 0;
while (idx < toInsert.length) {
if (signal && signal.aborted) return;
await this._nextFrame();
if (signal && signal.aborted) return;
const batch = document.createDocumentFragment();
const end = Math.min(idx + CHUNK, toInsert.length);
for (let i = idx; i < end; i++) batch.appendChild(toInsert[i]);
body.insertBefore(batch, refNode);
idx = end;
}
}
this._finishLiveInsert(signal, toInsert);
}
/**
* live apply 全部完成后的收尾(audit R7b P1-16 / R8 P2-14 / P2-16):
* heading observer 与 resolveRelativeImages 都依赖「完整 DOM」,
* 必须等所有变更落地再跑,否则 observer 盯半截 DOM / 图片漏解析。
*
* @param {AbortSignal|null} signal
* @param {Node[]} changedRoots - 本次新插入的顶级块(P2-14:图片解析只扫
* 这些块;未变更块的 img 上一轮已解析过,重扫是纯浪费)。传 [] 表示
* 零变更;loadContent 等非 diff 路径不传(= 全 body 扫描)。
*/
_finishLiveInsert(signal, changedRoots) {
if (signal && signal.aborted) return;
// P2-16 契约(与 notes-2c 对齐):observer 实例持久化,但每次重建必须
// 全量 re-observe(renderer-M1 历史教训)。diff 保留了 heading 节点引用,
// 但 re-observe 的成本 O(heading 数 5-50) 可忽略,不为此破坏统一语义。
this._rebuildHeadingObserver();
// fire-and-forget,与 loadContent 路径一致;catch 兜底避免 unhandled rejection。
this.resolveRelativeImages(this.currentFile ? this.currentFile.path : null, changedRoots)
.catch((e) => console.warn('[viewer] resolveRelativeImages 失败:', e && e.message));
}
/**
* 等下一帧。jsdom / 老环境没有 requestAnimationFrame 时退化为 setTimeout(0)。
* 独立成方法方便测试替换 / 未来统一调度。
*/
_nextFrame() {
return new Promise((resolve) => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
}
/**
* 测试钩子:等当前 in-flight 的 chunked insert 完成(含被 abort 的 ——
* promise 已 .catch 兜底,永远 resolve)。
*/
_test_liveInsertDone() {
return this._liveInsertPromise || Promise.resolve();
}
/**
* 当前在视口里"最顶部可见"的 heading
* @returns {{level:1|2|3, text:string, id:string}|null}
@@ -626,15 +1061,19 @@ export class MarkdownViewer {
}
/**
* 重建 heading IntersectionObserver
*
* 每次 loadContent / loadContentLive 都会调用 —— 因为 DOM 全换了,
* 旧的 observer 持有的 DOM 节点已经无效,必须重建。
* 重建 heading 观察:每次 loadContent / loadContentLive 都会调用 —— 因为
* DOM 全换了,旧观察目标节点已 detach,必须重新 observe 新节点。
* (P2-16 后 observer 实例本身持久化,见函数体注释。)
*/
_rebuildHeadingObserver() {
// P2-16 (audit):observer 实例持久化 —— 不再每次 new IntersectionObserver。
// loadContentLive 在 split 模式每个 debounced 击键都跑一次重建,旧实现
// 每次都分配 observer 对象 + 回调闭包。现在实例只建一次,重建只做
// disconnect + 全量 re-observe。
// 注意:「全量 re-observe」不能省(renderer-M1 历史教训:innerHTML 替换
// detach 旧节点,增量 observe 会盯着死节点,heading 追踪冻死)。
if (this._headingObserver) {
try { this._headingObserver.disconnect(); } catch { /* ignore */ }
this._headingObserver = null;
}
if (!this.bodyElement) return;
const headings = this.bodyElement.querySelectorAll('h1, h2, h3');
@@ -652,67 +1091,71 @@ export class MarkdownViewer {
// 记录当前观察的 heading id 列表(调试 / 复用参考)
this._observedHeadingIds = headings.length === 0 ? [] : Array.from(headings).map(h => h.id);
if (headings.length === 0) {
// 清空旧文档遗留的 heading 引用,避免指向已 detach 的节点(非空分支才在下方重建
// _headingNodes / _headingVisibleIds,提前 return 时要补,否则 _onHeadingIntersection
// 在空文档上仍持有旧节点引用)。
this._headingNodes = [];
this._headingVisibleIds = null;
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);
// P2-16:回调改读实例状态(_headingNodes / _headingVisibleIds),
// 不再闭包捕获本次的 headings / visibleIds —— 闭包捕获会让旧 observer
// 持有旧 NodeList,也正是「必须每次 new」的根因;实例状态化之后
// observer 可以跨重建存活。
this._headingNodes = Array.from(headings);
this._headingVisibleIds = new Set();
if (!this._headingObserver) {
this._headingObserver = new IntersectionObserver((entries) => {
this._onHeadingIntersection(entries);
}, {
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 this._headingNodes) this._headingObserver.observe(h);
}
/**
* heading IntersectionObserver 回调(P2-16 抽成实例方法,供持久化 observer 复用)。
*
* 把"当前可见的 heading 集合"实时维护:进入带 → 加入;离开带 → 移除。
* 最后 emit 的 currentHeadingId 是"位置最低且仍在带内"的(最接近顶部
* 但还没滚走的那个 —— 这就是用户在阅读的章节)。
* @param {IntersectionObserverEntry[]} entries
*/
_onHeadingIntersection(entries) {
const headings = this._headingNodes || [];
const visibleIds = this._headingVisibleIds;
if (!visibleIds) return;
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;
}
// 注:`next === null` 在此处不可达 —— 进入本分支前 `if (next === null && visibleIds.size === 0) return;`
// 已把「带内全空」的情况拦截;若 next 为 null 则 visibleIds 必为空,早已 return。
// 故这里无需再处理「滚过头」回退逻辑(那段 getBoundingClientRect 计算是死代码,已删除)。
if (next !== this.currentHeadingId) {
this.currentHeadingId = next;
this._emitHeadingChange();
}
}
/**

View File

@@ -103,7 +103,7 @@ export function mountModal({
<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>
<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>

View File

@@ -143,7 +143,7 @@ export function showPromptDialog({
// ('prompt() is and will not be supported'),正好是我们要绕开的坑。
// 兜底行为:当作取消处理(resolve null),并 toast 提示用户,让重要
// 操作(重命名 / 新建文件)不会静默无反应(audit L2 #7)。
showToast('当前操作被另一个对话框占用,请先关闭后再试', 'warning');
showToast('已有对话框打开,请先关闭后再试', 'warning');
resolve(null);
return;
}

View File

@@ -452,7 +452,7 @@ export class SettingsDialog {
// 3 个 radio 都已是默认值时按钮应禁用 —— click 后立即重算。
updateResetBtnState();
showToast('已恢复外观默认值,点保存后生效', 'info', 1800);
showToast('外观已恢复默认,点保存后生效', 'info', 1800);
}, { signal });
// 暴露给 _bindAppearancePickers 的 change 监听器:用户改 picker 后实时
@@ -570,7 +570,7 @@ export class SettingsDialog {
// 审计修复 (Round 11 P2-4):去掉重复的 applyHint('openai')。
// 上面 dispatch + openaiRadio.checked 已触发 aiProviderRadios 的 change
// 监听器跑过一次 applyHint,再调一次是冗余。
showToast('已清空 AI 配置,点保存后生效', 'info', 1800);
showToast('AI 配置已清空,点保存后生效', 'info', 1800);
// focus 到 Base URL 让用户能直接接着填;不放 model 是因为清空后
// 通常会先确认/切换 provider,再选对应模型。
aiBaseUrlInput.focus();
@@ -683,8 +683,24 @@ export class SettingsDialog {
// 只提供「配色(palette)」,因为它没有顶部按钮入口。
const palette = THEME_PALETTES.some(p => p.value === initial.themePalette) ? initial.themePalette : 'default';
const paletteLabel = THEME_PALETTES.find(p => p.value === palette)?.label || palette;
const fs = READER_FONT_SIZES.includes(initial.readerFontSize) ? initial.readerFontSize : 17;
const lh = READER_LINE_HEIGHTS.includes(initial.readerLineHeight) ? initial.readerLineHeight : 1.85;
// 显示列表:若当前值在 schema 合法范围但不在 UI 枚举里(用户手改 config.json 设了 20),
// 把它前置为可选项并选中,避免打开设置一保存就静默回落到 17(丢弃用户真实设置)。
const fsList = READER_FONT_SIZES.includes(initial.readerFontSize)
? READER_FONT_SIZES
: (typeof initial.readerFontSize === 'number' && Number.isInteger(initial.readerFontSize)
&& initial.readerFontSize >= 12 && initial.readerFontSize <= 24)
? [initial.readerFontSize, ...READER_FONT_SIZES]
: READER_FONT_SIZES;
const fsFallback = READER_FONT_SIZES.includes(17) ? 17 : (READER_FONT_SIZES[0] ?? 17);
const fs = initial.readerFontSize != null && fsList.includes(initial.readerFontSize)
? initial.readerFontSize : fsFallback;
const lhList = READER_LINE_HEIGHTS.includes(initial.readerLineHeight)
? READER_LINE_HEIGHTS
: (typeof initial.readerLineHeight === 'number' && initial.readerLineHeight >= 1.4 && initial.readerLineHeight <= 2.2)
? [initial.readerLineHeight, ...READER_LINE_HEIGHTS]
: READER_LINE_HEIGHTS;
const lh = initial.readerLineHeight != null && lhList.includes(initial.readerLineHeight)
? initial.readerLineHeight : 1.85;
// 「恢复默认」按钮的初始 disabled 态:当前 3 个视觉字段都已经是 schema 默认值时禁用。
// 默认值在 _render 入口已统一计算到 this._appearanceDefaults。
@@ -711,8 +727,8 @@ export class SettingsDialog {
`;
}).join('');
// 字号卡片:5 张卡,每张渲染一个 "Aa" 样本
const fontSizeCards = READER_FONT_SIZES.map((n) => {
// 字号卡片:5 张卡,每张渲染一个 "Aa" 样本(fsList 可能含用户手设的自定义值)
const fontSizeCards = fsList.map((n) => {
const isSelected = n === fs;
return `
<label class="picker-card fontsize-card ${isSelected ? 'is-selected' : ''}" data-picker="fontsize" data-value="${n}" style="--sample-size:${n}px">
@@ -724,8 +740,8 @@ export class SettingsDialog {
`;
}).join('');
// 行距卡片:4 张卡,每张渲染 3 行示例文本
const lineHeightCards = READER_LINE_HEIGHTS.map((n) => {
// 行距卡片:4 张卡,每张渲染 3 行示例文本(lhList 可能含用户手设的自定义值)
const lineHeightCards = lhList.map((n) => {
const isSelected = n === lh;
return `
<label class="picker-card lineheight-card ${isSelected ? 'is-selected' : ''}" data-picker="lineheight" data-value="${n}" style="--sample-lh:${n}">
@@ -912,7 +928,7 @@ print('hello')</code></pre>
</div>
${initial._aiKeyRevealFailed
? `<div class="form-hint form-hint-warning" role="status">
Key 已保存但当前无法回显。如要替换请输入完整新 Key;留空不会清空已有 Key(需清空请手动编辑 config.json)。
已保存的 Key 当前无法回显。输入完整新 Key 可替换;留空将保留原值。
</div>`
: ''}
</div>
@@ -937,7 +953,7 @@ print('hello')</code></pre>
</svg>
</span>
<div class="settings-callout-text">
API Key 明文存储于本机 <code>config.json</code>${initial._configName ? `${initial._configDirName ? `(位于 <code>${escapeAttr(initial._configDirName)}</code> 配置目录)` : ''}<button type="button" class="btn-link" data-action="open-config-dir">[打开文件夹]</button>` : ''}。每次请求以 HTTP header 形式发送至上方的 Base URL,请使用你信任的服务。
API Key 明文存储于本机 <code>config.json</code>${initial._configName ? `${initial._configDirName ? `(位于 <code>${escapeAttr(initial._configDirName)}</code> 配置目录)` : ''}<button type="button" class="btn-link" data-action="open-config-dir">打开文件夹</button>` : ''}。每次请求以 HTTP header 形式发送至上方填写的 Base URL,请使用你信任的服务。
</div>
</div>
@@ -950,7 +966,7 @@ print('hello')</code></pre>
rows="4"
placeholder="留空使用内置默认提示词"
></textarea>
<div class="form-hint">让 AI 按特定角色或格式回复;留空时使用内置默认。</div>
<div class="form-hint">让 AI 按特定角色或格式回复;留空时使用内置默认提示词。</div>
</div>
<div class="settings-callout settings-callout-danger" role="note">
@@ -976,7 +992,7 @@ print('hello')</code></pre>
// 直接禁用按钮 + tooltip 引导「手动编辑 config.json」,避免与 hint 自相矛盾。
? 'disabled aria-disabled="true" title="Key 无法回显,无法安全清空。请手动编辑 config.json。"'
: ''}
>[清空 AI 配置]</button>
>清空 AI 配置</button>
</div>
</div>
</section>
@@ -1250,13 +1266,14 @@ print('hello')</code></pre>
} catch (e) {
console.error('[settings-dialog] 保存失败:', e);
showToast(`保存失败:${e.message}`, 'error');
} finally {
// 统一收口:成功 / 失败 / 早退(DOM 缺失 / 无变更)三条路径都走这里。
// 用 finally 保证即便 try 内提前 return(如关键 DOM 元素缺失,line ~1174)
// 也能复位 _saving 与保存按钮 —— 否则 _saving 卡在 true 会让后续 _save
// 被 `if (this._saving) return` 误挡,必须 reload 才能恢复。
this._saving = false;
if (saveBtn) saveBtn.disabled = false;
}
// 统一收口:成功 / 失败 / 早退(DOM 缺失 / 无变更)三条路径都走这里。
// 之前只有 catch 分支复位 _saving —— 成功后 modal.close('save') 直接
// return,_saving 留在 true;再次 open() 会被 `if (this._saving) return`
// 误挡,必须 reload 才能恢复(按钮看起来"失灵",但实际上是状态卡死)。
this._saving = false;
if (saveBtn) saveBtn.disabled = false;
}
}

View File

@@ -12,6 +12,9 @@
// - Ctrl/Cmd + Shift+A 切换 AI 修改面板(编辑器内让给 CM6)
// - Ctrl/Cmd + W 关闭当前打开的文件(无文件时 no-op;模态打开时吞掉)
// - Esc(搜索框内) 清空搜索
// - Ctrl/Cmd + X 剪切侧栏选中的文件 / 文件夹到剪贴板(编辑器内让给 CM6)
// - Ctrl/Cmd + C 复制侧栏选中的文件 / 文件夹到剪贴板(编辑器内让给 CM6)
// - Ctrl/Cmd + V 把剪贴板里的条目粘贴到当前浏览目录(编辑器内让给 CM6)
//
// 注意:CM6 内部的 keymap 会优先拦截 Mod-s / Mod-f(在编辑器聚焦时),
// 但事件仍会继续冒泡到 document —— 所以这里必须主动避让,否则
@@ -35,6 +38,20 @@ function isInsideEditor(e) {
return !!target.closest('.cm-editor');
}
/**
* 事件焦点是否在侧栏 / 文件列表内。
* 只有焦点落在侧栏(含 .file-list 里的 .file-item)时,Ctrl+X/C/V 才作用于
* 选中的文件条目;否则(编辑器内 / 阅读视图 / 搜索框)让浏览器原生
* 复制 / 粘贴文本,不被快捷键劫持。
* @param {Event} e
* @returns {boolean}
*/
function isSidebarFocused(e) {
const target = e.target;
if (!target || typeof target.closest !== 'function') return false;
return !!(target.closest('.sidebar') || target.closest('.file-list'));
}
/**
* @param {object} handlers
* @param {() => void} handlers.onFocusSearch
@@ -48,6 +65,9 @@ function isInsideEditor(e) {
* @param {() => void} [handlers.onToggleAi] - Ctrl/Cmd+Shift+A(AI 修改面板)
* @param {() => void} [handlers.onReload] - Ctrl/Cmd+R(README 之前提到但未注册——audit 补全)
* @param {() => void} [handlers.onCloseFile] - Ctrl/Cmd+W(关闭当前打开的文件;无文件时 no-op)
* @param {() => void} [handlers.onCut] - Ctrl/Cmd+X(剪切侧栏选中条目到剪贴板)
* @param {() => void} [handlers.onCopy] - Ctrl/Cmd+C(复制侧栏选中条目到剪贴板)
* @param {() => void} [handlers.onPaste] - Ctrl/Cmd+V(把剪贴板条目粘贴到当前目录)
*/
export function mountKeyboardShortcuts({
onFocusSearch,
@@ -61,6 +81,9 @@ export function mountKeyboardShortcuts({
onToggleAi,
onReload,
onCloseFile,
onCut,
onCopy,
onPaste,
}) {
// audit fix (Phase O-M2):提取命名 handler 让 dispose 能 removeEventListener。
// 之前匿名函数 addEventListener 后无法解绑,测试 beforeEach 反复 mount
@@ -95,12 +118,17 @@ export function mountKeyboardShortcuts({
const inEditor = isInsideEditor(e);
// 模态打开时禁用所有 Ctrl/Cmd 类快捷键,避免抢焦点或顶替对话框。
// 模态打开时禁用大部分 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
// 模态内大部分文件类快捷键吞掉,但 Ctrl+S 是例外 —— 下面 SUPPRESS_KEYS 不收 's',
// 事件会落到文件末尾的 Ctrl+S 分支(line ~158)继续调 onSave 保存底层文档,
// 与编辑器内 Ctrl+S 行为一致(设置对话框自身也在内部监听 Ctrl+S 做「模态内快捷保存」,
// 并用 stopPropagation 阻止冒泡,这里无需拦截;开着普通 confirm/prompt 时
// 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();
@@ -204,6 +232,24 @@ export function mountKeyboardShortcuts({
return;
}
// Ctrl/Cmd + X / C / V → 剪切 / 复制 / 粘贴侧栏选中的文件条目。
// 仅在焦点位于侧栏(.sidebar / .file-list)时生效:
// - 编辑器内 → 让给 CM6 的原生文本复制 / 剪切 / 粘贴
// - 阅读视图 / 搜索框 → 让给浏览器原生文本复制
// 与上方其它 Ctrl+letter 分支一样,IME 合成期已在 handler 顶部被 isComposing 守卫拦截。
if (!e.shiftKey && !e.altKey) {
const k = e.key.toLowerCase();
if (k === 'x' || k === 'c' || k === 'v') {
if (inEditor) return;
if (!isSidebarFocused(e)) return;
e.preventDefault();
if (k === 'x' && onCut) onCut();
else if (k === 'c' && onCopy) onCopy();
else if (k === 'v' && onPaste) onPaste();
return;
}
}
// Ctrl/Cmd + W → 关闭当前打开的文件。
// 与 Ctrl+R 不同:必须 preventDefault —— 否则 Electron 默认行为是直接
// 关闭 BrowserWindow,把应用整个干掉(不是"关文件")。这也让 SUPPRESS_KEYS

View File

@@ -1,7 +1,11 @@
/* ===========================================================================
Notes — 样式入口(Stage 4a:分片)
通过 @import 按顺序串联 01..12。
浏览器原生 CSS @import 性能成本可忽略(12 个文件并行或合并);本应用零打包。
P2-8(audit):index.html 已不再走这个单入口 —— 主应用直接并列 12 个
<link rel="stylesheet">(并行 fetch,级联顺序 = 文档顺序)。本文件保留给
tests/manual/*.html 手工验证页用(那里不在乎串行 fetch)。
新增分片时这里与 index.html 的 12 个 <link> 两处同步。
=========================================================================== */
@import url('./styles/01-tokens.css');

View File

@@ -9,7 +9,7 @@
- 设计令牌:间距、字号、颜色、阴影、动画曲线统一管理
- 浅色 / 深色主题:暖色调低饱和度,长时间阅读舒适
- 排版:行高 1.85、字号 16px、优化的中英文字体栈
- 排版:行高 1.85、正文 17px(--reader-font-size)、优化的中英文字体栈
- 组件:浮动卡片、柔和阴影、克制的圆角
=========================================================================== */

View File

@@ -21,6 +21,16 @@
视觉上完全无差异(实色背景上看不出毛玻璃),但每次滚动都是几十 KB 像素的
重复 GPU 工作。 */
border-bottom: 1px solid var(--border-subtle);
/* 切主题时与 body 同步淡入淡出:03-base.css body 已声明
transition: background var(--transition-slow), color var(--transition-slow);
不在 .toolbar 显式声明会让 --bg-toolbar 跳色而 body 还在过渡,
形成 ~320ms 的色块错位(顶部硬切、主区淡变)。
border-color 同理:1px 底分隔线用 --border-subtle 不加 transition 会
出现"主色在淡、这条 1px 边线硬切"的视觉跳变。background 已经在 body 那条
慢节奏下淡变,border-color 用同样的 --transition-slow 节奏保持一致。 */
transition:
background var(--transition-slow),
border-color var(--transition-slow);
z-index: var(--z-toolbar);
position: relative;
/*
@@ -202,18 +212,24 @@ body.has-app-icon .app-title::before {
.window-controls {
display: flex;
align-items: center;
margin-left: 4px;
/* 让最右侧的关闭按钮贴齐视口右沿(沿用平台常见 UX),但避免裁掉 hover 区;
之前用 -8px 时会把按钮裁进视口外 8px。改为 -2px 把贴边视觉压回视口内 */
margin-right: -2px;
/* 左侧 4px padding + 0 右侧:让最小化按钮与上一组 toolbar-divider 之间
留出和 chip 一致的呼吸(原本用 margin-left:4px),但保留右侧 flush
视觉(贴近视口右沿,沿用 Win/Linux 平台 UX)。原来用 margin-right:-2px
把关闭按钮"挤"到视口边;改 padding-right:0 让容器本身在 toolbar 4px
右 padding 之内、关闭按钮不再依赖负 margin 也能落在合理边缘。 */
padding-left: 4px;
padding-right: 0;
margin-left: 0;
gap: 0;
/* 用渐变分隔线替代纯色 —— 比 border 精致,符合 macOS 视觉 */
position: relative;
}
.window-controls::before {
/* 分隔线相对容器定位在 -4px:与 padding-left 等距,让「上一组按钮 →
divider → 窗口控制组」节奏与工具栏内其他 divider 间距一致。 */
content: "";
position: absolute;
left: -8px;
left: -4px;
top: 50%;
transform: translateY(-50%);
height: 20px;
@@ -491,7 +507,7 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
Toolbar Search(设计优化 2026-09:从侧栏 header 迁来)
------------------------------------------------------------
把搜索文件框从侧栏上移到底部顶栏首位,让侧栏垂直空间专注于文件列表
(侧栏同时把「+ 新建笔记」合并进 sort 行 —— 双重省空间)。
(侧栏的新建按钮已挪到右键菜单,首屏只留排序条)。
- flex:0 1 240px:理想宽度 240px,可缩不可涨 —— 窗口变窄时它先缩,
不挤右侧按钮组;不抢占侧栏上方视觉重量
- min-width:160px:极限窄窗口下保留可用宽度(低于此值键盘用户看不清)
@@ -617,6 +633,35 @@ body[data-theme="light"] #btn-theme .theme-icon-sun {
}
}
/* 极窄窗口(≤ 720px,含 600px 最小窗口):工具栏右簇(搜索 + 外观 + 视图 +
重置布局 + 自动保存 + 保存/关闭/设置 + 窗口控件)在 600px 视口下会溢出约 90px,
把最右侧的窗口控件(最小化/最大化/关闭)挤出视口 —— frameless 窗口下这是唯一
的关闭入口,被裁掉等于「关不掉窗口」。这里只收起最低优先级的控件腾出空间,
保证窗口控件始终可达:
- 隐藏「重置布局」按钮组 + 它后面的分隔线(低频操作,仍可经右键菜单/设置恢复)
- 自动保存折叠为纯图标(隐藏文字标签 + iOS 开关,保留按钮本体与 aria-pressed
状态色;完整开关仍在设置里),约省 27px
- 搜索框 min-width 进一步降到 72px(720px 断点晚于 720px 断点,这里后定义以覆盖,
同时兜住 701–719px 这段原始布局仍会轻微溢出的过渡带)
三者合计约省 90px,窗口控件在 600px 最小宽度下不再被裁。 */
@media (max-width: 720px) {
#tg-reset-layout,
#tg-reset-layout + .toolbar-divider {
display: none;
}
#toolbar-autosave .btn-label,
#toolbar-autosave .toggle-switch {
display: none;
}
#toolbar-autosave {
padding: 0 6px;
}
.toolbar-search {
flex: 0 1 120px;
min-width: 72px;
}
}
/* ============================================================
App Shell
============================================================ */

View File

@@ -16,10 +16,9 @@
/* 宽度由 .app-shell 的 grid 模板通过 --sidebar-width 决定;这里不再写死 px */
min-width: 0;
/* container query 锚点:让 .sidebar 内部元素能根据侧栏实际宽度自适应。
用途:当侧栏拖到 ~240px 以下时,「新建笔记」内联按钮的"新建笔记"文字
自动隐藏、只保留 + 图标,给 sort 按钮(「名称」「修改时间」)腾空间。
230px 是经验阈值:sort 文字 + icon + 内边距刚好能完整显示的临界点。
命名空间 "sidebar" 与下面 @container 配套,避免与未来其他容器冲突 */
下方可用 @container sidebar (max-width: ...) 写响应式规则,
跟随 .sidebar 实际渲染宽度,跟主窗口大小解耦。
命名空间 "sidebar" 与配套 @container 一致,避免与未来其他容器冲突 */
container-type: inline-size;
container-name: sidebar;
}
@@ -31,10 +30,10 @@
.sidebar-sort 同一行的右端(用 .sidebar-new-btn--inline + margin-left:auto),
节省侧栏首屏的垂直空间。原独立行的顶部呼吸转移到了 .sidebar-sort 的 padding-top) */
/* 「+ 新建笔记」按钮:全宽、带文字的主操作按钮
——accent 文字 + 微微的 accent 底色 + 1px accent 边框,hover/active 进一步加深。
/* 主操作按钮:全宽、带文字(accent 文字 + 软底色 + 1px accent 边框的主操作语义)。
新建文件 / 新建文件夹挪到右键菜单后,这个 base 类现在只被侧栏底部 AI 按钮(#btn-ai)复用。
2026-08 把字号从 12px 提到 14px、字重 500 → 600、加 8% accent 底色 + 32% accent 边框,
解决「新建笔记」字看着发淡、按钮太瘦的问题 */
解决按钮文字发淡、按钮太瘦的问题 */
.sidebar-new-btn {
display: flex;
align-items: center;
@@ -76,113 +75,65 @@
outline-offset: 1px;
}
/* 「新建笔记」按钮的紧凑内联变体(.sidebar-new-btn--inline):
与 .sidebar-sort-btn 并排在 .sidebar-sort 行内,靠 margin-left:auto 推到右端。
设计目标:
1) 与 sort 按钮同高(覆盖 base 的固定 height:34px → 自适应高度),
否则 34px 的新建笔记会撑爆整行、视觉上"跳"出来,盖过 sort 按钮;
2) 宽度按内容(flex:0 0 auto),不抢 sort 按钮的弹性空间;
3) icon 同步缩到 13px(base 是 14px),与 sort 按钮视觉重量一致;
4) 保留 accent 文字 + 软底色 + 1px accent 边框的主操作语义不变。
—— base 类继续被侧栏底部 AI 按钮(#btn-ai)复用,仍是 34px 全宽语义。 */
.sidebar-new-btn--inline {
flex: 0 0 auto;
margin-left: auto;
width: auto;
height: auto;
/* 5/6 padding 与 sort 按钮对齐:整行按钮横向 padding 一致,视觉上"一排",
而不是 sort 紧、新建笔记松。同节奏点击区也更整齐 */
padding: 5px 6px;
font-size: var(--fs-sm);
letter-spacing: var(--tracking-wide);
}
.sidebar-new-btn--inline svg {
width: 13px;
height: 13px;
}
/* 内联态下取消 base 的 scale 反馈 —— 整行按钮高度统一,按下时整行一起缩更协调;
若只缩新建笔记,按下瞬间会显得"错位跳出",与同行 sort 按钮的 active 态不齐 */
.sidebar-new-btn--inline:active {
transform: none;
}
/* 窄侧栏适配:当侧栏 ≤260px 时,「新建笔记」文字隐藏,只显 + 图标,
给 sort 按钮腾出 ~50px 空间,「修改时间」能完整显示。
container query(而不是 @media viewport):跟随 .sidebar 实际渲染宽度,
跟主窗口大小解耦。@supports 检测兼容性:旧 Chromium 不支持 container
query 会跳过规则,回退到全文字(最差也是 ellipsis,不算破图)
阈值 260px 是经验值:sort 「修改时间」4 字 + 新建笔记 4 字同时显示需要
~250px+ 侧栏宽度(实测 240px 时 sort 仍被截断),取 260px 给点余量 */
@supports (container-type: inline-size) {
@container sidebar (max-width: 260px) {
.sidebar-new-btn--inline span {
/* 视觉上隐藏文字,但保留 a11y / 屏幕阅读器朗读「新建笔记」 */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sidebar-new-btn--inline {
/* 只显图标时,按钮收紧到 icon + 少量水平内边距即可 */
padding: 5px 8px;
}
}
}
/* (.sidebar-new-btn--inline 已删除 —— 2026-09 设计优化:新建文件 / 新建文件夹
从侧栏「+」按钮挪到右键菜单(侧栏空白右键、文件夹条目右键),内联变体
.sidebar-new-btn--inline 及其窄侧栏 container query 规则随之移除。base 类
.sidebar-new-btn 仍被侧栏底部 AI 按钮(#btn-ai)复用) */
/* (search-wrap / search-icon / search-input 已移到 04-toolbar.css —— 2026-09
设计优化:搜索框从侧栏上移到工具栏,样式跟随 HTML 一起走) */
/* ============================================================
Sidebar 排序工具条 + 新建笔记(合并到一行)
Sidebar 排序工具条
紧贴它作用的 .file-list 上方 —— 这是「列表的工具」,
不应该埋进三层深的设置对话框(见 [[feedback-prefer-near-affected-area]])。
设计目标:紧凑到不抢搜索框注意力,active 态明确但不过重。
2026-09 优化:「新建笔记」从独立行合并到右端,
margin-left:auto 把新建笔记推到行尾,与左侧 sort 按钮形成「列表的工具 vs 主操作」的层次。
gap 同步从 2px → 4px:插入「新建笔记」后与 sort 按钮之间需要稍宽间距,避免视觉粘连。
新建文件 / 新建文件夹已挪到右键菜单,侧栏首行只剩排序工具条。
============================================================ */
.sidebar-sort {
display: flex;
align-items: center;
/* gap 2px:sort 按钮之间 2px(连成一组 —— 都是"列表的工具"),
margin-left:auto 把"新建笔记"推到最后,自然形成「sort 组」与「主操作」的分组 */
gap: 2px;
/* gap:sort 触发器与未来同组控件之间的间距("列表的工具"成组) */
gap: 4px;
/* 顶 8px / 底 8px:sort 是侧栏首屏第一行,工具栏底边已经有 ~8px breathing,
这里再给 8px 顶距足够拉开层次(10px → 8px 省 2px 给文件列表);
上下对称让 sort 行居中感更强,border-bottom 自然分隔 */
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
/* 切主题时 border-subtle 颜色变化需要 fade —— 否则 1px 分隔线在 body
320ms 慢淡变过程中硬切。背景是 transparent,没 background 跳变可淡 */
transition: border-color var(--transition-slow);
}
.sidebar-sort-btn {
/* Sort 下拉容器:
2026-10 设计改造 —— 把原来并排两个 sort 按钮(.sidebar-sort-btn)换成下拉:
- 一个触发器占 sort 行左段,按内容宽度(flex:0 0 auto),不抢其它控件空间
- 菜单 absolute 定位在触发器下方,absolute 让它能浮在文件列表之上不受
flex 行高度限制,也不挤压下方列表布局
drop-shadow 比纯 border 更柔和:菜单浮起感强,与触发器同源色调一致
*/
.sidebar-sort-dropdown {
position: relative;
flex: 1 1 0;
min-width: 0;
}
.sidebar-sort-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
/* 横向 padding 8px → 6px:sort 标签"修改时间" 4 字 + icon 在默认 220px 侧栏下
需要 ~83px,原 5/8 padding + 4px row gap 把每个 sort 按钮挤到 80px,
「修改时间」就被截断成「修改时」。收到 6px 后单 sort 按钮宽度降到 76px,
省下来的 4×2=8px 让 sort 行整体宽松,可完整显示 4 字标签 */
width: 100%;
min-width: 0;
padding: 5px 6px;
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,
挤压下方文件列表)。即使不够也用省略号截断,保持一行紧凑 */
/* 防止长 label「按修改时间→旧」在窄侧栏里换行塌成两行 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -192,31 +143,180 @@
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 底色仍明确选中,但字不再"贴脸"。 */
/* 触发器打开态:accent-soft 底色 + accent 边框让「选中的排序」在行内就有
视觉反馈,不必打开菜单也能看出当前排序 */
.sidebar-sort-trigger[aria-expanded="true"],
.sidebar-sort-trigger.is-active {
color: var(--accent-text);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
}
.sidebar-sort-btn.is-active:hover {
.sidebar-sort-trigger:hover {
color: var(--fg-secondary);
background: var(--bg-hover);
}
.sidebar-sort-trigger[aria-expanded="true"]:hover,
.sidebar-sort-trigger.is-active:hover {
/* accent-soft + bg-hover 18% 混合:暗色调色板上 hover 不冲掉选中底色 */
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
}
.sidebar-sort-btn:focus-visible {
.sidebar-sort-trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.sidebar-sort-btn svg {
/* chevron 在打开时旋转 180°,暗示菜单已展开。
旋转而非切图标 —— 单一 SVG,CSS transform 更顺。
hover 提亮到 1.0:与 .status-chip-mode:hover svg 同源(默认 0.7 → 满透明),
鼠标停上时给 chevron 一抹"亮起来"的反馈,配合 trigger 底色变化更协调 */
.sidebar-sort-trigger-chevron {
flex: 0 0 auto;
margin-left: auto;
opacity: 0.7;
transition:
transform var(--transition-fast),
opacity var(--transition-fast);
}
.sidebar-sort-trigger[aria-expanded="true"] .sidebar-sort-trigger-chevron {
transform: rotate(180deg);
}
.sidebar-sort-trigger:hover .sidebar-sort-trigger-chevron {
opacity: 1;
}
/* trigger label 同样吃掉多余宽度,触发器 overflow:hidden 时它先被截。
flex:1 1 auto + min-width:0 是 flex 子项允许收缩到 content 以下的标配 —— 不加 min-width:0,
label 默认 min-content 宽度(最长不可断行片段),触发器 overflow:hidden 时它不会缩,
而是溢出 trigger 边缘把 chevron 顶出去。ellipsis 也得在 label 自己被约束时才生效。 */
.sidebar-sort-trigger-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Sort 菜单:
- absolute 在触发器下方 left:0 让左对齐(与触发器同宽),top:calc(100% + 4px) 留 4px gap
- min-width:100% 而不是 width:100%:菜单可超出触发器宽度,长 label 「按修改时间」不挤
- padding:4px 让菜单项四周呼吸,menu 自身 padding 不参与 grid 列表
bg-elevated + drop-shadow + radius-sm:浮起卡片感,区别于触发器内联工具条 */
.sidebar-sort-menu {
position: absolute;
left: 0;
top: calc(100% + 4px);
/* min-width 让菜单始终覆盖触发器宽度,长 label(如「按修改时间」)不挤压;
长 label 单独成项时菜单按最长一项自然变宽 */
min-width: 100%;
max-width: 240px;
margin: 0;
padding: 4px;
list-style: none;
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
/* var(--shadow-md) 与 .focus-exit-hint 同源(4px 12px + 2px 4px 投影组合),
浮层用它来给菜单"轻浮起"层次。原来的硬编码 0.18/0.08 透明度是 dark-only 的
调参值,亮主题下显得偏重;用 token 后亮 / 暗两套主题的阴影会自动套用
02-palettes.css 已校准好的 0.07-0.32 区间,色彩系统内自洽 */
box-shadow: var(--shadow-md);
z-index: 50;
/* 淡入比瞬切更柔和;50ms 极短避免拖慢菜单 */
animation: sidebar-sort-menu-in 80ms ease-out;
}
@keyframes sidebar-sort-menu-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.sidebar-sort-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: var(--radius-sm);
color: var(--fg-secondary);
font-size: var(--fs-sm);
font-weight: 500;
cursor: pointer;
user-select: none;
transition: background var(--transition-fast), color var(--transition-fast);
}
.sidebar-sort-menu-item:hover,
.sidebar-sort-menu-item:focus-visible {
background: var(--bg-hover);
color: var(--fg-primary);
}
/* 键盘 focus 需要可见环 —— menuitem 默认 outline:none(tabindex="-1" 的 li
浏览器不再画默认环),用 :focus-visible 而非 :focus:点击菜单项瞬间
focus 会落到它身上,若用 :focus 也会触发"高亮",给视觉多余干扰。
负 offset 让环贴 item 内沿,与 trigger focus-visible 的 +1px 外环形成
"内 vs 外"层次(trigger 是浮起控件,环外贴;menu item 已有 padding 4px
的 ul 容器,环内贴不会跟外沿视觉挤在一起) */
.sidebar-sort-menu-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.sidebar-sort-menu-item.is-active {
/* 与触发器一致:accent-text + accent-soft;选中态双处呼应(触发器 + 菜单项) */
color: var(--accent-text);
background: var(--accent-soft);
}
.sidebar-sort-menu-item.is-active:hover {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-hover));
}
/* 选中态左侧打勾;占位用 inline-block 让未选中项也对齐预留 13px + 8px gap。
比 flex 子项条件 display 更稳 —— 不参与排序语义,避免影响 item 高度 */
.sidebar-sort-menu-check {
display: inline-flex;
width: 13px;
height: 13px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
color: var(--accent);
opacity: 0;
}
.sidebar-sort-menu-item.is-active .sidebar-sort-menu-check {
opacity: 1;
}
.sidebar-sort-menu-text {
flex: 1 1 auto;
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-sort-menu-hint {
/* hint 用 fg-muted + 小字号,与主 label 拉开对比;不抢主 label 视觉重量 */
color: var(--fg-muted);
font-size: var(--fs-xs);
font-weight: 400;
flex: 0 0 auto;
}
/* 菜单分组分隔:1px 水平细线 + 上下各 4px 留白。
margin 而不是 padding 让 flex 容器布局不挤压 —— padding 会撑大 li 高度,
上下 4px margin 让「组间距」发生在 li 边缘,与菜单项 5px padding 对称。 */
.sidebar-sort-menu-separator {
list-style: none;
height: 1px;
margin: 4px 6px;
padding: 0;
background: var(--border-subtle);
pointer-events: none;
}
.file-list {
@@ -231,6 +331,25 @@
gap: 1px;
/* 滚动条预留空间 —— 让滚动条 hover 才出现,不抢视觉 */
scrollbar-gutter: stable;
/* audit fix (R7a P1-15):windowed render 需要把 .file-item 用
position:absolute + top:N*h 钉在指定位置,所以容器得 position:relative
作为锚点。flex + position:relative 不冲突(容器本身仍是 flex 布局,
决定 sentinel / 非 windowed 模式项的位置)。 */
position: relative;
}
/* audit fix (R7a P1-15):windowed 虚拟滚动 sentinel。
- 真实高度 = N * itemHeight,给滚动条撑出总高
- 默认 flex 项目,不抢视觉(aria-hidden + 0 padding)
- JS 切到 windowed 模式时 appendChild 它;切回 full / 空 状态时 remove
用 <li> 而非 <div> 是因为 <ul> 只能装 <li> 子元素,HTML 规范硬约束 */
.file-list-sentinel {
flex-shrink: 0;
padding: 0;
margin: 0;
list-style: none;
/* 高度由 JS 直接 style.height 设置;这里只保证不引入意外 padding */
pointer-events: none;
}
.file-list:empty::after {
@@ -245,7 +364,7 @@
color: var(--fg-tertiary);
font-size: var(--fs-sm);
line-height: var(--lh-relaxed);
/* 留点上方间距,与上方 sort/新建笔记行拉开 */
/* 留点上方间距,与上方 sort 行拉开 */
margin-top: var(--space-2);
/* 避免一行字过长在窄侧栏里截断为半个字 */
word-break: break-word;
@@ -260,6 +379,11 @@
grid-template-columns: 16px 1fr;
align-items: center;
column-gap: 10px;
/* audit fix (R7a P1-15):windowed 虚拟滚动需要固定高度做索引 × h 的
位置数学。padding 仍由 box-sizing:border-box 包含在 height 内。
49px (12 padding + 37 content) → 显式 50px,差 1px 由 align-items:center
吸收,视觉无变化但 JS 可以用 N * 50 直接算 top/总高。 */
height: 50px;
/* 上下 padding 9px → 6px:原 9px 在 13px name + 13px meta 两行下让单条 file-item
撑到 ~60px,50 个文件就要 3000px 滚动,太松。压到 6px 后 ~50px/item,
列表更紧凑、可见项更多,仍保留点击区域与 hover 反馈呼吸 */
@@ -337,8 +461,18 @@
纯色 accent 在 #2a325c 类 accent-soft 背景下,RGB 距离很短,
加 4px 投影能把色条对比拉高一档 */
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 40%, transparent);
/* 选中态色条从左侧滑入:切换当前文件时给一点"落位"的过渡,
比硬切更有手感,又不拖沓(200ms)。reduced-motion 下被全局 .01ms 规则收掉 */
transform-origin: left center;
animation: file-active-bar 200ms var(--ease-out);
}
@keyframes file-active-bar {
from { transform: translateY(-50%) scaleX(0); opacity: 0; }
to { transform: translateY(-50%) scaleX(1); opacity: 1; }
}
.file-name {
font-size: var(--fs-sm);
color: var(--fg-primary);
@@ -404,6 +538,9 @@
display: flex;
flex-direction: column;
gap: 2px;
/* 与 .sidebar-sort 同源:footer 顶 1px 分隔线在切主题时也需 fade,
避免与 main 320ms 慢淡变错位 */
transition: border-color var(--transition-slow);
}
/* ============================================================
@@ -434,6 +571,38 @@
overflow-wrap: break-word;
}
/* ============================================================
阅读进度条:viewer 顶部的 3px 细线,随滚动从左向右填充。
- sticky top:0:作为 .viewer 滚动容器的首个子元素,滚动时钉在顶部,
正文从它下方滑过(markdown-body 顶部有 48px padding,细线落在留白里不压字)。
- pointer-events:none:纯视觉反馈,不挡滚动 / 点击。
- 底色透明,只有填充段可见;90ms linear 跟随滚动手感顺滑。
- 宽度由 JS(updateStatusScroll)按 scrollTop / (scrollHeight-client) 写入。
============================================================ */
.reading-progress {
position: sticky;
top: 0;
z-index: 2;
flex: 0 0 auto;
width: 100%;
height: 3px;
background: transparent;
pointer-events: none;
}
.reading-progress-bar {
display: block;
height: 100%;
width: 0%;
/* 渐变比纯 accent 多一档层次,末端圆角呼应 file-item 色条 */
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-hover) 100%);
border-radius: 0 2px 2px 0;
/* 极轻辉光,让细线在深色底上"浮"起来一点,但不喧宾夺主 */
box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 50%, transparent);
transform-origin: left center;
transition: width 90ms linear;
}
/* 空状态:更有温度的视觉 */
.empty-state {
flex: 1;
@@ -538,9 +707,14 @@
text-wrap: pretty;
}
/* 二级提示:用 tertiary + 略小字号,与主 hint 拉开视觉权重 */
/* 二级提示:用 secondary + 略小字号,与主 hint 拉开视觉权重
P2-26:tertiary → secondary。dark palette 下
--fg-tertiary #8e95a1 vs --fg-secondary #b0b6c0
后者在 #08090a 底色上对比度 ~7.5:1(前者 ~5.0:1,边缘档)。
改 secondary 满足 WCAG AAA (7:1+),且不破坏主 hint(用 tertiary 仍
区分"主 → 次"权重)。其他 4 个 palette 同理。 */
.empty-state .hint.hint-secondary {
color: var(--fg-tertiary);
color: var(--fg-secondary);
font-size: var(--fs-xs-readable);
/* margin-top 6 → 10px:原 6px 让主 hint 与次 hint 之间几乎贴住,
加上两行 lh-relaxed (1.7) 后视觉上更挤。提到 10px 让两组 hint
@@ -551,6 +725,62 @@
line-height: 1.55;
}
/* 空状态 CTA ——「+ 新建笔记」按钮(audit P2-25)
与 .sidebar-new-btn 共享 accent 配色语义但不走 width:100%:
空状态是居中布局,紧凑 auto-width 让视觉重心聚焦在按钮上,
不会撑满整行让空白左右显得"摊"。
设计选择:
- 颜色用 --accent + --btn-on-accent(与 .btn-primary 一致)—— 走主按钮语义,
让"下一步该做什么"的指令最强
- padding 9px 16px:比 .btn (12px 20px) 收紧一点,与 .sidebar-new-btn 视觉重量一致
- hover 不上 transform scale —— 空状态按钮是用户在"看到指引后第一次行动",
缩放会让人犹豫到底点没点中;改用 box-shadow 上抬作"立起来"反馈
- focus-visible 沿用全局 .btn 的 2px accent outline(见 08-modal.css),
这里只声明 offset 让焦点环在按钮内贴边,与全局外 2px 视觉一致 */
.empty-state-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
/* 9px 16px → 10px 18px:与下方 hint secondary 拉开一档垂直间距的同时,
比 .sidebar-new-btn (34px 高) 略紧凑 —— 空状态是「轻量引导」,
不需要 34px 那么"正式"的按钮高度 */
padding: 10px 18px;
margin-top: var(--space-4);
background: var(--accent);
color: var(--btn-on-accent, #ffffff);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
font-weight: 600;
letter-spacing: var(--tracking-tight);
cursor: pointer;
/* hover 时阴影抬升比 scale 更克制;同时阴影从 accent-soft 升级到
accent-soft-hover,给"按钮立起来"的微动效暗示 */
box-shadow: 0 2px 6px var(--accent-soft);
transition:
background var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.empty-state-cta:hover {
background: var(--accent-hover);
box-shadow: 0 4px 12px var(--accent-soft-hover);
}
.empty-state-cta:active {
/* 0.97 比其它按钮的 0.96 略小 —— 空状态 CTA 是"重决策",按下时
反馈更明显一点,避免用户怀疑"按了没生效" */
transform: scale(0.97);
}
.empty-state-cta:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.empty-state .hint kbd {
/* .md 这种代码 chip 用 kbd 标签更语义化 */
/* padding 2/7 → 1/6:在 12px 字号下原 2/7 让 kbd chip 显得"鼓鼓的",
@@ -848,3 +1078,71 @@ body[data-editor-mode="split"] .splitter {
opacity: 0.4;
}
/* ============================================================
Folder Browser:拖拽 + 剪贴板视觉反馈
- .file-item 默认 draggable=true(文件 / 文件夹 / 二进制都可拖)
- 拖拽中源项 .is-dragging 半透明;合法投放目标 folder .is-drop-target 给
accent 虚线描边 + 软底高亮;剪切到剪贴板的源项 .is-clipboard-cut 用
虚线 + 弱化提示「将被移走」。
============================================================ */
.file-item[draggable="true"] {
/* 默认已是 cursor:pointer;拖拽态单独给 grabbing 反馈 */
cursor: grab;
}
.file-item.is-dragging {
opacity: 0.4;
cursor: grabbing;
}
.file-item.is-drop-target {
/* 合法投放目标:accent 虚线描边 + 软底,明显区别于 hover 态 */
outline: 2px dashed var(--accent);
outline-offset: -2px;
background: var(--accent-soft);
}
.file-item.is-clipboard-cut {
/* 已剪切待移动的源项:弱化 + 虚线边框,暗示「即将离开此处」 */
opacity: 0.6;
outline: 1px dashed var(--border-default);
outline-offset: -2px;
}
/* audit R7a P1-20:分页「加载更多」按钮
视觉上是一个普通 li 但居中、有虚线分隔;hover 时背景色加深 + cursor:pointer。
跟普通 .file-item 共享 padding / font 但不要 icon,简洁一些。 */
.file-list-load-more {
list-style: none;
padding: 10px 12px;
margin: 4px 8px;
border: 1px dashed var(--border-default);
border-radius: 6px;
text-align: center;
color: var(--fg-secondary);
font-size: var(--fs-sm);
cursor: pointer;
user-select: none;
transition: background 0.1s, border-color 0.1s;
}
.file-list-load-more:hover,
.file-list-load-more:focus-visible {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--fg-primary);
outline: none;
}
/* audit R7a P1-15:windowed 虚拟滚动 —— JS 切到该模式时给 .file-item 加
.is-windowed 类,position:absolute 让 N*50 位置数学生效,left/right
撑满容器,padding 仍然在 .file-item 上(box-sizing:border-box,
height:50px 已含 padding)。
top 由 JS 直接 style.top = N*50+'px' 设置,不写进 CSS(每个 item 不同)。
切回 full 模式时 JS 移除 .is-windowed 与 style.top,flex 布局自然复位。 */
.file-item.is-windowed {
position: absolute;
left: 10px; /* 抵消 .file-list 的 padding-left 10px */
right: 10px; /* 抵消 .file-list 的 padding-right 10px */
}

View File

@@ -24,6 +24,8 @@
letter-spacing: var(--tracking-tight);
scroll-margin-top: var(--space-5);
font-family: var(--font-sans);
/* 多行标题均匀分布,避免末行孤字;仅优化换行分布,不改量 */
text-wrap: balance;
}
.markdown-body h1 {
@@ -78,8 +80,16 @@
.markdown-body p {
margin: 0;
text-align: justify;
/* 行高 / 长串断行已由 05-sidebar.css 的 .markdown-body 基础规则统一处理
(line-height: var(--reader-line-height)、overflow-wrap: break-word),
这里不再重复。 */
/* justify + CJK/emoji 在窄栏下会出现"两端对齐、字间距爆炸";改回 start
后长行左对齐、短行自然断行,整体节奏更稳。hyphens 保留:英文单词在
窄 viewport 下仍可被连字符断开 */
text-align: start;
hyphens: auto;
/* 避免段落末行只剩一个孤字/孤标点,提升阅读收尾的舒适感 */
text-wrap: pretty;
}
.markdown-body a {
@@ -210,6 +220,42 @@
font-weight: 400;
}
/* 代码块复制按钮:浮动 FAB,悬浮代码块时贴到右上角外缘浮出。
刻意不进 <pre> 子树(见 MarkdownViewer._setupContentInteractions),
以免破坏 loadContentLive 的 isEqualNode 前缀/后缀 diff。
hidden 属性负责显隐(同时移出 tab 顺序 + 可访问性正确)。 */
.code-copy-fab {
position: fixed;
z-index: 30;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--fg-secondary);
border: 1px solid var(--border-default);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.code-copy-fab:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.code-copy-fab:active { transform: scale(0.94); }
.code-copy-fab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.code-copy-fab.is-copied {
color: var(--success);
border-color: color-mix(in srgb, var(--success) 40%, transparent);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
/* 水平分割线 */
.markdown-body hr {
border: none;

View File

@@ -24,6 +24,13 @@
letter-spacing: var(--tracking-wide);
/* audit fix (P2):与 toolbar 同步去掉 backdrop-filter —— 实色背景上看不出
毛玻璃效果,但每帧重算模糊成本真实存在(1200×24 px × 60 fps = 大量 GPU)。 */
/* 切主题时与 body 同步淡入淡出:与 .toolbar 同源问题(见 04-toolbar.css
.toolbar { transition: background var(--transition-slow) } 注释),
不声明会让 --bg-toolbar 跳色而 body 还在过渡,形成 ~320ms 色块错位。
border-color 同理:1px 顶分隔线在切主题时硬切会跟主区淡变错位。 */
transition:
background var(--transition-slow),
border-color var(--transition-slow);
}
/* 三段式状态栏:左段(弱化路径)/ 中段(核心字数)/ 右段(chip 簇) */
@@ -108,9 +115,10 @@
flex: 0 0 auto;
width: 1px;
height: 10px;
/* 圆角胶囊状的点状分隔符,比纯色线条更精致 */
/* 1px 宽 + 10px 高的纯竖线:border-radius 1px 在这种尺寸下既看不出"胶囊"
也"做隐形",留着反而像漏改。改 0 让分隔线回归"实线"语义。 */
background: var(--border-default);
border-radius: 1px;
border-radius: 0;
}
.status-meta {
@@ -271,6 +279,16 @@
flex: 0 0 auto;
}
/* 章节切换时平滑淡入(随滚动触发,由 app.js 重触发动画)。
reduced-motion 下被 10-a11y.css 的 .01ms 规则收掉,不残留动画。 */
.status-heading.is-updating {
animation: status-heading-fade 240ms var(--ease-out);
}
@keyframes status-heading-fade {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.status-chip-muted {
background: transparent;
color: var(--fg-tertiary);
@@ -425,8 +443,11 @@
animation: toast-in 280ms var(--ease-out);
max-width: 480px;
white-space: pre-wrap;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
/* audit fix (P3):与 chrome 类保持 backdrop-filter 策略一致 —— toolbar /
statusbar 已因「实色上看不出毛玻璃、但 GPU 每帧重算成本真实」摘掉。
toast 用 var(--bg-elevated) 实色背景,去掉 blur 后视觉几乎无差
(短时浮现 + 不透明底色遮住一切),却避免每帧重算这块接近 viewport
1/3 宽的模糊覆盖区。 */
/* 可选 icon:用 grid 而不是 inline-flex 是为了 icon 缺失时布局也稳定 */
display: inline-grid;
grid-template-columns: auto 1fr;

View File

@@ -23,8 +23,10 @@
position: fixed;
inset: 0;
background: var(--scrim);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
/* audit P3 一致性:去掉 backdrop-filter blur(4) —— 与 toolbar / statusbar /
toast / context-menu 同源:实色 --scrim 看不出毛玻璃,每帧重算 4px 模糊
区域的 GPU 成本却是真实的。modal 内是 bg-elevated 实色卡片,背景 scrim
本就被压成纯黑,再糊一次无视觉差异。 */
z-index: var(--z-modal);
display: flex;
align-items: center;
@@ -183,8 +185,9 @@
/* 文件路径 / 配置 id 等"代码类"内容才用等宽字体 */
.form-input-mono {
font-family: var(--font-mono);
/* 13 → 12.5:mono 字符本身比 sans 宽,略缩小避免路径溢出按钮 */
font-size: 12.5px;
/* 13 → 12:mono 字符比 sans 宽,用标准令牌 --fs-xs-readable(12px) 略缩小避免路径溢出按钮,
同时对齐设计令牌体系(不再用非标 12.5px) */
font-size: var(--fs-xs-readable);
}
/* M-series fix (audit Phase M-CSS-11):AI 系统提示词框等 textarea 之前
@@ -193,7 +196,7 @@
JSON / 模板)+ 最小 4 行高度,避免双击 textarea 后立刻只有 1 行。 */
.form-input.form-textarea {
font-family: var(--font-mono);
font-size: 12.5px;
font-size: var(--fs-xs-readable);
line-height: 1.5;
min-height: calc(1.5em * 4 + 20px); /* 4 行 + 上下 padding */
resize: vertical;
@@ -230,7 +233,7 @@
}
.form-error {
margin: 4px 0 0;
font-size: 12px;
font-size: var(--fs-xs-readable);
line-height: 1.45;
color: var(--accent-text);
}
@@ -501,7 +504,7 @@
.settings-callout-text code {
font-family: var(--font-mono);
font-size: 11.5px;
font-size: var(--fs-xs-readable);
padding: 1px 5px;
background: var(--bg-hover);
border-radius: 3px;
@@ -557,8 +560,8 @@
background: var(--bg-hover);
padding: 1px 5px;
border-radius: 3px;
/* 11 → 12:与外层 subtitle 一致,避免 code 反而更小 */
font-size: 12px;
/* 12 = --fs-xs-readable:与外层 subtitle 一致,避免 code 反而更小 */
font-size: var(--fs-xs-readable);
}
/* 关于段:开发者信息 + 主页链接 —— 不可改,只展示。 */
@@ -593,8 +596,7 @@
.about-link {
margin-left: 0;
font-family: var(--font-mono);
/* URL 一般偏长,13px 即可;与 .form-input-mono 同字号 */
font-size: 12.5px;
font-size: var(--fs-xs-readable);
/* 长 URL 默认会在空格 / 标点断行;强制按字符断,避免撑出右侧 padding */
word-break: break-all;
}
@@ -942,9 +944,10 @@
.aiprovider-card {
display: grid;
/* icon 24px + body 1fr:去掉旧版的 `auto` 第三列(.aiprovider-protocol),
那列重复了 hint 文本里的协议路径,且把 body 挤窄。 */
grid-template-columns: 24px 1fr;
/* icon 列用 auto 而非固定 24px:wordmark "Claude"(约 50px 宽)放进 24px 轨道
会被裁切;auto 让轨道按药丸实际内容宽度撑开,同时 body(1fr) 仍占满剩余宽度。
去掉旧版第三列 .aiprovider-protocol(重复 hint 协议路径)。 */
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
/* 沿用 .picker-card 通用 padding 即可;AI 卡片是横向布局,
@@ -957,8 +960,11 @@
}
.aiprovider-icon {
width: 24px;
/* 轨道改 auto 后,药丸按内容宽度撑开("Claude" 约 50px),不再被裁切;
高度仍 24px 与其余 picker 图标对齐,white-space:nowrap 保证字样不换行。 */
height: 24px;
padding: 0 7px;
white-space: nowrap;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent);

View File

@@ -48,8 +48,10 @@
display: flex;
flex-direction: column;
gap: 2px;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
/* audit fix (P3):与 chrome 类保持 backdrop-filter 策略一致 —— 与 .toast
同源(07-statusbar.css .toast 注释),实色背景上看不出毛玻璃,反而
每次滚动主区都强制 GPU 重算菜单覆盖区域的模糊。菜单小但触发频繁,
切目录 / 右键都会重新出现,累积 GPU 成本不容忽视。 */
animation: context-menu-in 120ms var(--ease-out);
user-select: none;
/* 阻止菜单自身的文本被选中 */
@@ -151,6 +153,119 @@ body[data-focus-mode="true"] .ai-chat-dock {
transition: opacity var(--transition-fast, 120ms ease-out);
}
/* ============================================================
图片 lightbox(点击正文图片放大)
全局浮层,挂在 document.body(不进 <img>,避免破坏 live diff)
============================================================ */
.image-lightbox {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-7);
}
.image-lightbox[hidden] { display: none !important; }
.image-lightbox-backdrop {
position: absolute;
inset: 0;
background: color-mix(in srgb, #000 72%, transparent);
}
[data-theme="light"] .image-lightbox-backdrop {
background: color-mix(in srgb, #000 52%, transparent);
}
.image-lightbox-stage {
position: relative;
max-width: 92vw;
max-height: 88vh;
display: flex;
align-items: center;
justify-content: center;
animation: image-lightbox-in 180ms var(--ease-out);
}
.image-lightbox-img {
max-width: 92vw;
max-height: 88vh;
width: auto;
height: auto;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
background: var(--bg-card);
}
.image-lightbox-close {
position: absolute;
top: -12px;
right: -12px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg-elevated);
color: var(--fg-primary);
border: 1px solid var(--border-default);
box-shadow: var(--shadow-md);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.image-lightbox-close:hover {
background: var(--accent-soft);
color: var(--accent);
}
.image-lightbox-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes image-lightbox-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
/* ============================================================
工具栏快捷键 chip(hover 主按钮时浮现,只读 data-shortcut,
不改动按钮 DOM。pointer-events:none → 纯视觉提示,不挡 hover)
============================================================ */
.shortcut-hint {
position: fixed;
z-index: 1100;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px 6px;
border-radius: var(--radius-sm);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
box-shadow: var(--shadow-md);
color: var(--fg-secondary);
pointer-events: none;
animation: shortcut-hint-in 140ms var(--ease-out);
}
.shortcut-hint[hidden] { display: none !important; }
.shortcut-hint kbd {
font-family: var(--font-mono);
font-size: var(--fs-xs-readable);
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
background: var(--bg-app);
color: var(--fg-primary);
border: 1px solid var(--border-subtle);
box-shadow: 0 1px 0 var(--border-subtle);
}
.shortcut-hint-plus {
color: var(--fg-tertiary);
font-size: 0.8em;
padding: 0 1px;
}
@keyframes shortcut-hint-in {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
/*
BUG-3 fix (audit):focus mode 下 .ai-chat-dock 即便被 opacity:0 隐藏,
仍然占据 body grid row 3 的自然高度(~88px),把 app-shell 高度挤掉。
@@ -240,7 +355,10 @@ body[data-focus-mode="true"][data-editor-mode="split"] .splitter {
border-radius: 3px;
background: var(--bg-card);
font-family: var(--font-mono);
font-size: 10px;
/* 10 → 11(--fs-xs):focus exit hint 在右下角停留 ~600ms 后淡出,
10px 在低 DPI 屏易糊;提到 11px 与 .toolbar / .empty-state kbd 同档,
鼠标停在窗口里那一瞥就能看见。 */
font-size: var(--fs-xs);
margin: 0 1px;
}
body[data-focus-mode="true"]:hover .focus-exit-hint {

View File

@@ -21,7 +21,7 @@
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 13px;
font-size: var(--fs-sm);
font-weight: 500;
/* 默认视觉隐藏但保留可访问性 —— 用 transform 移到屏外,不影响
屏幕阅读器在第一个 Tab 焦点候选里看到它 */
@@ -65,5 +65,11 @@
animation-duration: 0.01ms !important;
animation-delay: 0 !important;
}
/* 阅读进度条是 width 过渡(非 animation),上面的 .01ms 规则收不掉它。
关掉过渡让进度"瞬切",避免每帧滚动触发持续的位置变化动画(前庭敏感用户)。 */
.reading-progress-bar { transition: none; }
/* 代码块复制按钮的 hover/active 颜色过渡 + 按下缩放也一并收掉 */
.code-copy-fab { transition: none; }
.code-copy-fab:active { transform: none; }
}

View File

@@ -267,7 +267,7 @@ body[data-focus-mode="true"] .ai-splitter {
font-size: var(--fs-xs-readable);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.ai-diff-apply-all:hover {
@@ -429,7 +429,7 @@ body[data-focus-mode="true"] .ai-splitter {
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
transition: background var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.ai-diff-region-apply:hover:not([disabled]) {
@@ -466,7 +466,7 @@ body[data-focus-mode="true"] .ai-splitter {
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
}
.ai-diff-region-skip:hover {
@@ -645,6 +645,32 @@ body[data-focus-mode="true"] .ai-splitter {
color: var(--fg-tertiary);
}
/* P2-23:「⚙ 设置」内联链 —— 未配置时显示在 input 右侧,提示用户去填 Base URL / API Key。
小尺寸 + accent 色,与 submit 按钮拉开视觉权重(链接 ≠ 操作)。hidden 时
[hidden] 默认 display:none 即可,无需额外 display 控制。 */
.ai-chat-configure-link {
flex: 0 0 auto;
margin-left: 6px;
height: 28px;
padding: 0 10px;
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--fs-sm);
cursor: pointer;
}
.ai-chat-configure-link:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.ai-chat-configure-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ai-chat-submit {
height: 32px;
/* P2-6 fix (audit):min-width 锁定宽度,防止"生成修改"(4字)和
@@ -661,7 +687,7 @@ body[data-focus-mode="true"] .ai-splitter {
文字在同一基线垂直居中。 */
line-height: 30px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.ai-chat-submit:hover:not([disabled]) {
@@ -697,7 +723,7 @@ body[data-focus-mode="true"] .ai-splitter {
/* ============================================================
AI 按钮激活态(侧栏底部)
当 AI 面板展开时给 btn-ai 一个明显区别于"主操作描边态"的实心强调态:
- 关闭:沿用 .sidebar-new-btn 的描边 + 8% accent 底(与「新建笔记」同款)
- 关闭:沿用 .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 时不会把"已打开"的视觉态冲掉。