This commit is contained in:
2026-09-12 17:08:25 +08:00
parent 9c06d3f4be
commit a1089efd18
7 changed files with 476 additions and 22 deletions

View File

@@ -2,7 +2,7 @@
桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。 桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。
![tests](https://img.shields.io/badge/tests-643%2F29%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) ![tests](https://img.shields.io/badge/tests-642%2F28%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue)
## 简介 ## 简介
@@ -28,7 +28,7 @@ npm start
```bash ```bash
npm start # 启动UTF-8 包装,推荐;中文 Windows 别用 start:raw npm start # 启动UTF-8 包装,推荐;中文 Windows 别用 start:raw
npm test # 全部单测643 用例 / 29 文件) npm test # 全部单测642 用例 / 28 文件)
npm run test:watch # watch 模式 npm run test:watch # watch 模式
npm run check # 语法 + IPC 通道配对 npm run check # 语法 + IPC 通道配对
npm run lint # ESLint npm run lint # ESLint
@@ -114,28 +114,16 @@ npm run build:linux # Linux → dist/AppImage
``` ```
Notes/ Notes/
├── main.js # Electron 主进程 ├── main.js # Electron 主进程
├── main/ # 主进程模块ai / file-ops / fs-watcher / config-store ├── main/ # 主进程模块aifile-opsfs-watcherconfig-store
├── preload.js # IPC bridge ├── preload.js # IPC bridge
├── index.html # UI 框架 + CSP + importmap ├── index.html # UI 框架 + CSP + importmap
├── src/ # 渲染端 ├── src/ # 渲染端ai/ 面板、styles/ CSS 分片)
│ ├── ai/ # AI 面板chat / diff / status / controller
│ └── styles/ # CSS 分片tokens / palettes / base / … 12 个)
├── shared/ # 主进程 / preload / 渲染端共享 ├── shared/ # 主进程 / preload / 渲染端共享
│ ├── ai-errors.js
│ ├── extension-lists.js
│ ├── friendly-fs-error.js
│ ├── markdown-diff.js
│ ├── render-sanitize.js
│ ├── settings-schema.js
│ └── slug.js
├── data/welcome.md # 首次启动种子文件 ├── data/welcome.md # 首次启动种子文件
├── patches/ # patch-package 补丁app-builder-lib 26.15.3 ├── scripts/ # 启动包装launch.js+ 静态检查check-syntax / check-ipc
├── scripts/
│ ├── launch.js # UTF-8 包装启动
│ ├── check-syntax.js # 语法静态检查
│ └── check-ipc.js # IPC 通道配对检查
└── tests/ └── tests/
── unit/ # 29 个测试文件643 用例vitest ── unit/ # 28 个测试文件642 用例vitest
└── manual/ # 手工视觉验证产物(不进版本库)
``` ```
## 故障排除 ## 故障排除

View File

@@ -73,9 +73,13 @@ function linesToContextRows(content) {
* @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时 * @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时
* togglePanel 会拒绝打开 AI dock 并 toast双向互斥避免 AI dock 透过 modal * togglePanel 会拒绝打开 AI dock 并 toast双向互斥避免 AI dock 透过 modal
* 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。 * 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。
* @param {(isOpen:boolean) => void} [options.onDiffOpenChange] - AI 修复预览面板(中间 diff 面板)
* 显隐状态变化时回调true = 面板可见、false = 隐藏)。调用方收到 true 后可
* 强制编辑器切到编辑模式并禁用预览 / 双栏切换;收到 false 后还原。回调仅在
* 「与上一次状态不同」时触发,避免 setPendingDiff 内部多次刷新造成重复回调。
*/ */
export function createAiController({ export function createAiController({
api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen, api, editor, state, toast, getSettings, onAfterApply, onOpenSettings, notify, isSettingsOpen, onDiffOpenChange,
}) { }) {
if (!api || !api.aiEdit) { if (!api || !api.aiEdit) {
console.error('[ai-controller] api.aiEdit 不可用preload 没暴露 AI 桥?'); console.error('[ai-controller] api.aiEdit 不可用preload 没暴露 AI 桥?');
@@ -92,6 +96,9 @@ export function createAiController({
let requestGeneration = 0; let requestGeneration = 0;
// 防抖 timeronContentChanged 不再每个 keystroke 都跑 sync DP // 防抖 timeronContentChanged 不再每个 keystroke 都跑 sync DP
let recomputeTimer = null; let recomputeTimer = null;
// AI 修复预览显隐状态:用于在 syncBodyFlag 检测到状态翻转时回调 onDiffOpenChange。
// 初始 false —— pendingDiff 默认 null面板隐藏。
let diffOpenState = false;
// 安全 notify 闭包notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。 // 安全 notify 闭包notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。
// controller 在 4 处显式调AI_STATUS.DIFF / AI_STATUS.IDLE其余走 chatPanel.setStatus 转发。 // controller 在 4 处显式调AI_STATUS.DIFF / AI_STATUS.IDLE其余走 chatPanel.setStatus 转发。
@@ -205,6 +212,13 @@ export function createAiController({
(pendingDiff.regions || []).length > 0 (pendingDiff.regions || []).length > 0
); );
document.body.dataset.aiDiffOpen = has ? 'true' : 'false'; document.body.dataset.aiDiffOpen = has ? 'true' : 'false';
// 通知外部「AI 修复预览」显隐状态变化 —— 外部据此强制编辑器切到编辑模式
// 并禁用预览 / 双栏切换。仅在状态翻转时回调一次,避免 setPendingDiff 内部
// 多次刷新造成重复通知。
if (typeof onDiffOpenChange === 'function' && has !== diffOpenState) {
diffOpenState = has;
try { onDiffOpenChange(has); } catch { /* 不让 UI 异常阻断 AI 流程 */ }
}
} }
// 创建面板实例DOM 必须在 bindMountPoint 之后才能实例化) // 创建面板实例DOM 必须在 bindMountPoint 之后才能实例化)

View File

@@ -355,6 +355,36 @@ function syncDirtyFromEditor() {
return false; return false;
} }
/**
* AI 修复预览显隐 → 视图模式锁定
*
* AI 修复预览(中间 diff 面板)出现时:强制编辑器切到「编辑」模式;如果是预览 / 双栏
* 状态则一并跳到编辑。同时锁定模式 —— 期间不让切到 preview / split。
*
* AI 修复预览关闭时:解除模式锁,按当前 isMd 走回正常的 preview/split 禁用逻辑。
*
* 这是 ai-controller 的 onDiffOpenChange 回调的唯一职责,不在 controller 内做
* controller 不依赖 DOM 视图细节,仅 fire 事件),也不在 syncBodyFlag 内做
* (避免 controller 与 DOM 操作耦合)。
*
* @param {boolean} isOpen
*/
function handleAiDiffOpenChange(isOpen) {
if (isOpen) {
// 锁模式到 edit —— toolbar 的 setModeLock 会让 selectMode / cycle / 外部 setMode
// 都被吸到 edit用户点 preview / split 按钮、状态栏 chip、Ctrl+E 都无效。
editorToolbar.setModeLock('edit');
// 强制切到 edit即使当前已经在 edit 也无副作用 —— applyEditorMode 内部走 _setDOM
// focus: false 避免偷焦点:用户可能在 AI 输入框里编辑 prompt不应被打断。
applyEditorMode('edit', { focus: false });
} else {
// 解除锁;按持久化的 state.editorMode 走正常的 md/non-md 禁用逻辑
// (非 md 文件下 preview / split 仍会被禁用md 文件恢复可选)。
editorToolbar.setModeLock(null);
applyEditorMode(state.editorMode, { focus: false });
}
}
/** /**
* 同步侧栏底部 AI 按钮的 pressed 态 / hint —— 模块级 helper被 mountAiPanel * 同步侧栏底部 AI 按钮的 pressed 态 / hint —— 模块级 helper被 mountAiPanel
* 的 click handler 与 bootstrap 的 onToggleAi 快捷键 handler 共享。 * 的 click handler 与 bootstrap 的 onToggleAi 快捷键 handler 共享。
@@ -1384,6 +1414,9 @@ function mountAiPanel() {
// 期间手动点 AI 入口btn-ai / 状态栏 chip / 快捷键)会让 dock 透出 modal 背景后, // 期间手动点 AI 入口btn-ai / 状态栏 chip / 快捷键)会让 dock 透出 modal 背景后,
// 视觉重叠 + 误点。controller 用这个 getter 在 togglePanel 里把住开关。 // 视觉重叠 + 误点。controller 用这个 getter 在 togglePanel 里把住开关。
isSettingsOpen: () => state.settingsDialogOpen, isSettingsOpen: () => state.settingsDialogOpen,
// AI 修复预览面板(中间 diff 面板)显隐 → 视图模式锁定。打开时强制
// 切到编辑模式 + 禁用预览 / 双栏切换;关闭时还原。具体逻辑见 handleAiDiffOpenChange。
onDiffOpenChange: handleAiDiffOpenChange,
}); });
// 挂载 chat dock + diff 面板到各自的 DOM 容器 // 挂载 chat dock + diff 面板到各自的 DOM 容器
if (els.aiChatDock && els.appAi) { if (els.aiChatDock && els.appAi) {
@@ -3594,6 +3627,21 @@ function mountAiSplitter() {
return Math.abs(clientX - (r.x + r.width / 2)); return Math.abs(clientX - (r.x + r.width / 2));
} }
// 拖动起点的方向符号AI 面板的列位置随模式变split: AI 在 splitter 左 / 拖右 = AI
// 变大preview/edit + focus: AI 在 splitter 右且贴窗口右边缘 / 拖右 AI 向左扩张、
// splitter 视觉左移 → 与鼠标反向)。每次拖动重新比较 getBoundingClientRect
// splitter.x >= aiPanel.right ⇒ AI 在 splitter 左侧 ⇒ +1鼠标右移 AI 变大)
// splitter.x < aiPanel.left ⇒ AI 在 splitter 右侧 ⇒ -1鼠标右移 AI 变小)
// 两边都不成立splitter 跟 AI 重叠 / 不可见)兜底 +1避免异常路径写出 0 宽度。
function getDragSign() {
if (!els.appAi || !els.aiSplitter) return 1;
const ai = els.appAi.getBoundingClientRect();
const sp = els.aiSplitter.getBoundingClientRect();
if (sp.x >= ai.right - 0.5) return 1; // AI 在 splitter 左侧
if (sp.right <= ai.left + 0.5) return -1; // AI 在 splitter 右侧
return 1;
}
const splitter = createSplitter({ const splitter = createSplitter({
handle: els.aiSplitter, handle: els.aiSplitter,
axis: 'x', axis: 'x',
@@ -3601,6 +3649,7 @@ function mountAiSplitter() {
setSize: applyAiWidth, setSize: applyAiWidth,
clampSize: clampAiWidth, clampSize: clampAiWidth,
initialSize: getInitialWidth, initialSize: getInitialWidth,
getDragSign,
// audit #1持久化失败要让用户看到 —— 下次启动 AI 面板宽度丢失、用户不知情。 // audit #1持久化失败要让用户看到 —— 下次启动 AI 面板宽度丢失、用户不知情。
onDragEnd: () => { onDragEnd: () => {
const finalW = clampAiWidth(getAiPx()); const finalW = clampAiWidth(getAiPx());

View File

@@ -13,6 +13,10 @@ export class EditorToolbar {
constructor({ elements, onChange }) { constructor({ elements, onChange }) {
this.elements = elements; this.elements = elements;
this.onChange = onChange; this.onChange = onChange;
// 模式锁临时把视图锁在某个模式null = 不锁。用于「AI 修复预览
// 出现时强制编辑」等场景 —— setMode / selectMode / cycle 都会落到锁定值,
// 用户点 toolbar / 状态栏 chip / Ctrl+E 切不到其它模式。
this._modeLock = null;
if (elements.btnPreview) { if (elements.btnPreview) {
elements.btnPreview.addEventListener('click', () => this.selectMode('preview')); elements.btnPreview.addEventListener('click', () => this.selectMode('preview'));
@@ -25,12 +29,33 @@ export class EditorToolbar {
} }
} }
/**
* 临时锁定视图模式。锁定后任何切换(按钮 / cycle / 外部 setMode都会被吸到该模式。
* 传 null 解除锁定。锁定值必须为合法模式,否则忽略。
*
* 配合 applyEditorMode 使用:调用方先 setModeLock 再 applyEditorMode(锁定值)
* DOM / state 一起落到目标模式;解除时 setModeLock(null) 后 applyEditorMode(state.editorMode)
* 走正常的 md/non-md 禁用逻辑。
*
* @param {'preview'|'edit'|'split'|null} mode
*/
setModeLock(mode) {
if (mode === null) {
this._modeLock = null;
return;
}
if (!EDITOR_MODES.includes(mode)) return;
this._modeLock = mode;
}
/** /**
* 由用户点击按钮或外部 API 主动选择模式。触发 onChange用于持久化 * 由用户点击按钮或外部 API 主动选择模式。触发 onChange用于持久化
* @param {'preview'|'edit'|'split'} mode * @param {'preview'|'edit'|'split'} mode
*/ */
selectMode(mode) { selectMode(mode) {
if (!EDITOR_MODES.includes(mode)) return; if (!EDITOR_MODES.includes(mode)) return;
// 模式锁:拒绝一切「切到非锁定模式」的请求(按钮 / Ctrl+E / 状态栏 chip 都走这里)
if (this._modeLock !== null && mode !== this._modeLock) return;
if (this.getMode() === mode) return; // 同模式 → 不重复触发 if (this.getMode() === mode) return; // 同模式 → 不重复触发
this._setDOM(mode); this._setDOM(mode);
if (this.onChange) this.onChange(mode); if (this.onChange) this.onChange(mode);
@@ -39,11 +64,15 @@ export class EditorToolbar {
/** /**
* 同步 DOM 与按钮高亮,但**不**触发 onChange。 * 同步 DOM 与按钮高亮,但**不**触发 onChange。
* 用于「外部(持久化设置)改变了模式」的场景,避免循环。 * 用于「外部(持久化设置)改变了模式」的场景,避免循环。
*
* 模式锁生效时setMode 会被吸到锁定值 —— 这是关键applyEditorMode 被外部调用
* (比如 AI 修复预览打开时其它地方误触发 applyEditorMode('preview'))也会被纠正回锁定模式。
* @param {'preview'|'edit'|'split'} mode * @param {'preview'|'edit'|'split'} mode
*/ */
setMode(mode) { setMode(mode) {
if (!EDITOR_MODES.includes(mode)) return; if (!EDITOR_MODES.includes(mode)) return;
this._setDOM(mode); const target = this._modeLock !== null ? this._modeLock : mode;
this._setDOM(target);
} }
/** /**
@@ -56,8 +85,16 @@ export class EditorToolbar {
/** /**
* 三态循环preview → edit → split → preview * 三态循环preview → edit → split → preview
* 触发 onChange 回调。 * 触发 onChange 回调。
*
* 模式锁生效时cycle 不切模式(仅做一次 DOM 对齐,防御 lock 期间其它路径改了 dataset
*/ */
cycle() { cycle() {
if (this._modeLock !== null) {
if (this.getMode() !== this._modeLock) {
this._setDOM(this._modeLock);
}
return;
}
const cur = this.getMode(); const cur = this.getMode();
const idx = EDITOR_MODES.indexOf(cur); const idx = EDITOR_MODES.indexOf(cur);
const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length]; const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length];

View File

@@ -54,6 +54,13 @@
* @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners] * @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners]
* 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。 * 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。
* dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。 * dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。
* @property {() => (1 | -1)} [getDragSign] - 拖动起点回调,返回 dCoord 的符号:
* +1默认= 鼠标右移让目标变大;-1 = 鼠标右移让目标变小。
* 用于「目标元素可以在 splitter 两侧」的场景 —— 例如 AI 面板split 模式下
* AI 在 splitter 左边(鼠标右移 = AI 变大preview/edit + focus 模式下 AI 在
* splitter 右边且贴着窗口右边缘(鼠标右移 AI 会向左扩张splitter 视觉上反而
* 左移 → 与鼠标反向)。每次 startDrag 时调用一次,结果用于整次拖动。
* 不传 = 始终 +1语义同原 createSplitter老调用方无需改
*/ */
/** /**
@@ -93,6 +100,7 @@ export function createSplitter(opts) {
body = (typeof document !== 'undefined' ? document.body : null), body = (typeof document !== 'undefined' ? document.body : null),
hitZone = null, hitZone = null,
windowListeners = null, windowListeners = null,
getDragSign = null,
} = opts; } = opts;
if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素'); if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素');
@@ -119,6 +127,7 @@ export function createSplitter(opts) {
let pointerId = null; let pointerId = null;
let startCoord = 0; let startCoord = 0;
let startSize = 0; let startSize = 0;
let dragSign = 1; // 当前拖动的 dCoord 符号getDragSign 在 startDrag 时赋值
function coordOf(eventLike) { function coordOf(eventLike) {
return eventLike[coordKey]; return eventLike[coordKey];
@@ -130,6 +139,21 @@ export function createSplitter(opts) {
handle.classList.add('is-dragging'); handle.classList.add('is-dragging');
startCoord = coord; startCoord = coord;
startSize = getSize(); startSize = getSize();
// 拖动起点的 dCoord 符号getDragSign 让调用方按当前布局动态决定(+1 / -1
// AI 面板这种「目标元素可落在 splitter 任一侧」的场景,必须每次拖动重新检测,
// 否则切模式后方向仍按旧符号计算splitter 视觉上与鼠标反向。
// 兜底getDragSign 抛错或返回非 -1/1 时退化为 +1默认方向
// 保证一次坏回调不会让整次拖动卡死(用户能继续拖回正常状态)。
if (getDragSign) {
try {
const sign = getDragSign();
dragSign = sign === -1 ? -1 : 1;
} catch {
dragSign = 1;
}
} else {
dragSign = 1;
}
if (body) { if (body) {
body.style.cursor = cursor; body.style.cursor = cursor;
body.style.userSelect = 'none'; body.style.userSelect = 'none';
@@ -138,7 +162,7 @@ export function createSplitter(opts) {
function applyDrag(coord) { function applyDrag(coord) {
const dCoord = coord - startCoord; const dCoord = coord - startCoord;
setSize(clampSize(startSize + dCoord)); setSize(clampSize(startSize + dragSign * dCoord));
} }
function endDrag() { function endDrag() {

View File

@@ -227,3 +227,120 @@ describe('EditorToolbar a11y', () => {
expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']); expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']);
}); });
}); });
describe('EditorToolbar.setModeLockAI 修复预览场景)', () => {
it('锁到 editselectMode(\'preview\') / selectMode(\'split\') 被拒绝', () => {
// 场景AI 修复预览打开,强制编辑器锁在编辑模式;用户点预览 / 双栏按钮无效。
const els = setupDOM('preview');
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setModeLock('edit');
// 先把 dataset 落到锁定值(外部通常会接着调 applyEditorMode('edit')
tb.setMode('edit');
// 尝试切到 preview / split —— selectMode 应当 no-op拒绝非锁定模式
tb.selectMode('preview');
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
tb.selectMode('split');
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
// 切到锁定值本身:允许(同模式早退,不会触发 onChange
tb.selectMode('edit');
expect(onChange).not.toHaveBeenCalled();
});
it('锁到 editcycle 不切模式,只做一次 DOM 对齐', () => {
// 场景:用户按 Ctrl+E 或点状态栏 chip 触发 cycle —— 锁定期间不应跳到 split。
const els = setupDOM('split'); // 起始非锁定值,验证 cycle 会把它吸回 edit
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setModeLock('edit');
tb.cycle();
// cycle 应先把 DOM 对齐到 editdataset 改写),但不触发 onChange
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
// 再按一次:保持 edit仍不触发 onChange
tb.cycle();
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
});
it('锁到 editsetMode 强制对齐到 edit外部误调也能纠正', () => {
// 场景:锁定期间其它路径误调 setMode('preview') —— 仍应落到锁定值 edit。
const els = setupDOM('split');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.setModeLock('edit');
tb.setMode('preview');
expect(document.body.dataset.editorMode).toBe('edit');
tb.setMode('split');
expect(document.body.dataset.editorMode).toBe('edit');
});
it('按钮 click 走 selectMode锁定期间点预览 / 双栏按钮无反应', () => {
// 端到端:按钮 click → selectMode → 被锁拒绝。覆盖顶层 use case。
const els = setupDOM('preview');
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setModeLock('edit');
tb.setMode('edit');
els.btnPreview.click();
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
els.btnSplit.click();
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
// 编辑按钮仍然有效(点击 = 当前模式selectMode 同模式早退,无副作用)
els.btnEdit.click();
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
});
it('setModeLock(null):解除锁定后切模式恢复正常', () => {
const els = setupDOM('preview');
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setModeLock('edit');
tb.setMode('edit');
// 锁定期间尝试切到 split —— 被拒
tb.selectMode('split');
expect(document.body.dataset.editorMode).toBe('edit');
expect(onChange).not.toHaveBeenCalled();
// 解除锁定
tb.setModeLock(null);
// 切到 split 正常生效
tb.selectMode('split');
expect(document.body.dataset.editorMode).toBe('split');
expect(onChange).toHaveBeenCalledWith('split');
});
it('setModeLock 非法值:忽略(保持当前锁状态)', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.setModeLock('edit');
tb.setMode('edit');
// 非法值:保持 edit 锁定
tb.setModeLock('wide');
tb.selectMode('preview');
expect(document.body.dataset.editorMode).toBe('edit');
});
});

225
tests/unit/splitter.test.js Normal file
View File

@@ -0,0 +1,225 @@
// src/splitter.js —— 通用拖拽分割条工厂测试
//
// 覆盖 getDragSign 的方向反转逻辑:
// - 不传 getDragSign默认→ 鼠标右移让目标变大(+1
// - 传 getDragSign 返回 -1 → 鼠标右移让目标变小(用于 AI 面板在 splitter
// 右侧且贴窗口右边缘的场景,避免 splitter 视觉反向跟随鼠标)
// - getDragSign 抛错或返回非 -1/1 → 兜底 +1不让一次坏拖动摧毁整个交互
//
// 通过 jsdom 模拟 document/element绕过 mountAiSplitter 的 DOM 依赖,直接
// 驱动 createSplitter 的 mousedown/mousemove 路径验证 setSize 调用序列。
//
// 不覆盖:
// - 命中区hitZone扩展 → 那是 mountSidebarSplitter/mountAiSplitter 的
// 调用方职责,工厂只关心"接到 hit zone 触发后行为正确"。
// - touch 路径 → 与 mouse 路径走同一道 startDrag/applyDrag代码层同源。
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { createSplitter } from '../../src/splitter.js';
/**
* 在 jsdom 里构造一个最小可用的 splitter
* - handle 是按钮元素
* - getSize/setSize/clampSize/onDragEnd 由用例注入
* - 不传 hitZone / initialSize / windowListeners与 mountAiSplitter 主路径
* 解耦,单独验证方向反转这条新分支)
*
* 返回 {handle, fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls}
* - fireMouseDown(clientX) / fireMouseMove / fireMouseUp 模拟真实事件
* - setSizeCalls 记录所有 setSize 调用,可断言方向与幅度
*/
function makeSplitter({ getSize, setSize, clampSize, getDragSign }) {
const handle = document.createElement('div');
handle.style.width = '4px';
document.body.appendChild(handle);
const onDragEnd = vi.fn();
const inst = createSplitter({
handle,
axis: 'x',
getSize,
setSize,
clampSize,
onDragEnd,
getDragSign,
});
const setSizeCalls = [];
const wrappedSetSize = (px) => {
setSizeCalls.push(px);
setSize(px);
};
// 重新绑一个 setSize 用于观察
inst.dispose();
const inst2 = createSplitter({
handle,
axis: 'x',
getSize,
setSize: wrappedSetSize,
clampSize,
onDragEnd,
getDragSign,
});
const fireMouseDown = (clientX) => {
handle.dispatchEvent(new MouseEvent('mousedown', { clientX, bubbles: true }));
};
const fireMouseMove = (clientX) => {
document.dispatchEvent(new MouseEvent('mousemove', { clientX, bubbles: true }));
};
const fireMouseUp = (clientX = 0) => {
document.dispatchEvent(new MouseEvent('mouseup', { clientX, bubbles: true }));
};
return {
handle,
inst: inst2,
onDragEnd,
fireMouseDown,
fireMouseMove,
fireMouseUp,
setSizeCalls,
};
}
describe('createSplitter —— 方向反转 (getDragSign)', () => {
it('不传 getDragSign鼠标右移 → 目标变大(默认 +1', () => {
let current = 300;
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls, onDragEnd } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(200, Math.min(800, px)),
});
fireMouseDown(500);
fireMouseMove(560); // +60
fireMouseUp();
expect(setSizeCalls).toEqual([360]); // 300 + 60
expect(onDragEnd).toHaveBeenCalledWith(360);
});
it('getDragSign 返回 -1鼠标右移 → 目标变小AI 面板在 splitter 右侧场景)', () => {
let current = 360;
const signProvider = vi.fn(() => -1);
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls, onDragEnd } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(220, Math.min(720, px)),
getDragSign: signProvider,
});
fireMouseDown(500);
fireMouseMove(560); // +60 但应反向 → 减 60
fireMouseUp();
expect(setSizeCalls).toEqual([300]); // 360 - 60
expect(signProvider).toHaveBeenCalledTimes(1); // 仅 startDrag 时调用一次
expect(onDragEnd).toHaveBeenCalledWith(300);
});
it('getDragSign 返回 +1与默认行为一致防御性测试', () => {
let current = 360;
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(220, Math.min(720, px)),
getDragSign: () => 1,
});
fireMouseDown(500);
fireMouseMove(560);
fireMouseUp();
expect(setSizeCalls).toEqual([420]); // 360 + 60
});
it('getDragSign 在每次 startDrag 时重新求值(应对切模式)', () => {
let current = 360;
// 模拟「第一次拖动split 模式)= +1第二次拖动preview 模式)= -1」
const signProvider = vi.fn()
.mockReturnValueOnce(1)
.mockReturnValueOnce(-1);
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(220, Math.min(720, px)),
getDragSign: signProvider,
});
// 第一次拖动(+1鼠标右移 50 → current 应 +50
fireMouseDown(500);
fireMouseMove(550);
fireMouseUp();
expect(current).toBe(410);
// 第二次拖动(-1鼠标右移 50 → current 应 -50
fireMouseDown(500);
fireMouseMove(550);
fireMouseUp();
expect(current).toBe(360);
expect(signProvider).toHaveBeenCalledTimes(2);
expect(setSizeCalls).toEqual([410, 360]);
});
it('getDragSign 抛错 → 兜底 +1不让坏回调炸掉拖动', () => {
let current = 360;
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(220, Math.min(720, px)),
// 抛错 → 工厂内部 try/catch 兜底为 +1拖动继续按默认方向进行
getDragSign: () => { throw new Error('layout not ready'); },
});
fireMouseDown(500); // 内部吞掉 getDragSign 抛错dragSign = 1
fireMouseMove(560); // startSize(360) + dragSign(1) * +60 = 420
fireMouseUp();
expect(setSizeCalls).toEqual([420]);
});
it('getDragSign 返回非 -1/1 的奇怪值 → 归一为 +1', () => {
let current = 360;
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(220, Math.min(720, px)),
getDragSign: () => 0, // 0 不是合法符号 → 应被识别为 +1
});
fireMouseDown(500);
fireMouseMove(560);
fireMouseUp();
expect(setSizeCalls).toEqual([420]); // 360 + 60按 +1 算)
});
it('reverse 方向下 clamp 仍生效(防止拖出 MIN_WIDTH', () => {
let current = 230;
const { fireMouseDown, fireMouseMove, fireMouseUp, setSizeCalls } =
makeSplitter({
getSize: () => current,
setSize: (px) => { current = px; },
clampSize: (px) => Math.max(220, Math.min(720, px)),
getDragSign: () => -1,
});
fireMouseDown(500);
// 鼠标右移 100 → 当前期望 current - 100 = 130但 clamp 应钉到 MIN=220
fireMouseMove(600);
fireMouseUp();
expect(setSizeCalls).toEqual([220]);
});
});