update
This commit is contained in:
26
README.md
26
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
桌面端 Markdown 阅读与编辑器。读取指定文件夹里的 `.md` 文件,可就地编辑保存。
|
||||
|
||||
   
|
||||
   
|
||||
|
||||
## 简介
|
||||
|
||||
@@ -28,7 +28,7 @@ npm start
|
||||
|
||||
```bash
|
||||
npm start # 启动(UTF-8 包装,推荐;中文 Windows 别用 start:raw)
|
||||
npm test # 全部单测(643 用例 / 29 文件)
|
||||
npm test # 全部单测(642 用例 / 28 文件)
|
||||
npm run test:watch # watch 模式
|
||||
npm run check # 语法 + IPC 通道配对
|
||||
npm run lint # ESLint
|
||||
@@ -114,28 +114,16 @@ npm run build:linux # Linux → dist/(AppImage)
|
||||
```
|
||||
Notes/
|
||||
├── main.js # Electron 主进程
|
||||
├── main/ # 主进程模块:ai / file-ops / fs-watcher / config-store
|
||||
├── main/ # 主进程模块(ai、file-ops、fs-watcher、config-store)
|
||||
├── preload.js # IPC bridge
|
||||
├── index.html # UI 框架 + CSP + importmap
|
||||
├── src/ # 渲染端
|
||||
│ ├── ai/ # AI 面板(chat / diff / status / controller)
|
||||
│ └── styles/ # CSS 分片(tokens / palettes / base / … 12 个)
|
||||
├── src/ # 渲染端(ai/ 面板、styles/ CSS 分片)
|
||||
├── 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 # 首次启动种子文件
|
||||
├── patches/ # patch-package 补丁(app-builder-lib 26.15.3)
|
||||
├── scripts/
|
||||
│ ├── launch.js # UTF-8 包装启动
|
||||
│ ├── check-syntax.js # 语法静态检查
|
||||
│ └── check-ipc.js # IPC 通道配对检查
|
||||
├── scripts/ # 启动包装(launch.js)+ 静态检查(check-syntax / check-ipc)
|
||||
└── tests/
|
||||
└── unit/ # 29 个测试文件,643 用例(vitest)
|
||||
├── unit/ # 28 个测试文件,642 用例(vitest)
|
||||
└── manual/ # 手工视觉验证产物(不进版本库)
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
@@ -73,9 +73,13 @@ function linesToContextRows(content) {
|
||||
* @param (() => boolean) [options.isSettingsOpen] - 设置对话框当前是否打开。打开时
|
||||
* togglePanel 会拒绝打开 AI dock 并 toast(双向互斥,避免 AI dock 透过 modal
|
||||
* 半透明背景露在设置对话框背后造成视觉重叠 / 误点)。未传则视为永远关闭。
|
||||
* @param {(isOpen:boolean) => void} [options.onDiffOpenChange] - AI 修复预览面板(中间 diff 面板)
|
||||
* 显隐状态变化时回调(true = 面板可见、false = 隐藏)。调用方收到 true 后可
|
||||
* 强制编辑器切到编辑模式并禁用预览 / 双栏切换;收到 false 后还原。回调仅在
|
||||
* 「与上一次状态不同」时触发,避免 setPendingDiff 内部多次刷新造成重复回调。
|
||||
*/
|
||||
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) {
|
||||
console.error('[ai-controller] api.aiEdit 不可用;preload 没暴露 AI 桥?');
|
||||
@@ -92,6 +96,9 @@ export function createAiController({
|
||||
let requestGeneration = 0;
|
||||
// 防抖 timer:onContentChanged 不再每个 keystroke 都跑 sync DP
|
||||
let recomputeTimer = null;
|
||||
// AI 修复预览显隐状态:用于在 syncBodyFlag 检测到状态翻转时回调 onDiffOpenChange。
|
||||
// 初始 false —— pendingDiff 默认 null,面板隐藏。
|
||||
let diffOpenState = false;
|
||||
|
||||
// 安全 notify 闭包:notify 可选注入,未传则静默(与 panel 内部 _notify 同款语义)。
|
||||
// controller 在 4 处显式调(AI_STATUS.DIFF / AI_STATUS.IDLE),其余走 chatPanel.setStatus 转发。
|
||||
@@ -205,6 +212,13 @@ export function createAiController({
|
||||
(pendingDiff.regions || []).length > 0
|
||||
);
|
||||
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 之后才能实例化)
|
||||
|
||||
49
src/app.js
49
src/app.js
@@ -355,6 +355,36 @@ function syncDirtyFromEditor() {
|
||||
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
|
||||
* 的 click handler 与 bootstrap 的 onToggleAi 快捷键 handler 共享。
|
||||
@@ -1384,6 +1414,9 @@ function mountAiPanel() {
|
||||
// 期间手动点 AI 入口(btn-ai / 状态栏 chip / 快捷键)会让 dock 透出 modal 背景后,
|
||||
// 视觉重叠 + 误点。controller 用这个 getter 在 togglePanel 里把住开关。
|
||||
isSettingsOpen: () => state.settingsDialogOpen,
|
||||
// AI 修复预览面板(中间 diff 面板)显隐 → 视图模式锁定。打开时强制
|
||||
// 切到编辑模式 + 禁用预览 / 双栏切换;关闭时还原。具体逻辑见 handleAiDiffOpenChange。
|
||||
onDiffOpenChange: handleAiDiffOpenChange,
|
||||
});
|
||||
// 挂载 chat dock + diff 面板到各自的 DOM 容器
|
||||
if (els.aiChatDock && els.appAi) {
|
||||
@@ -3594,6 +3627,21 @@ function mountAiSplitter() {
|
||||
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({
|
||||
handle: els.aiSplitter,
|
||||
axis: 'x',
|
||||
@@ -3601,6 +3649,7 @@ function mountAiSplitter() {
|
||||
setSize: applyAiWidth,
|
||||
clampSize: clampAiWidth,
|
||||
initialSize: getInitialWidth,
|
||||
getDragSign,
|
||||
// audit #1:持久化失败要让用户看到 —— 下次启动 AI 面板宽度丢失、用户不知情。
|
||||
onDragEnd: () => {
|
||||
const finalW = clampAiWidth(getAiPx());
|
||||
|
||||
@@ -13,6 +13,10 @@ export class EditorToolbar {
|
||||
constructor({ elements, onChange }) {
|
||||
this.elements = elements;
|
||||
this.onChange = onChange;
|
||||
// 模式锁:临时把视图锁在某个模式(null = 不锁)。用于「AI 修复预览
|
||||
// 出现时强制编辑」等场景 —— setMode / selectMode / cycle 都会落到锁定值,
|
||||
// 用户点 toolbar / 状态栏 chip / Ctrl+E 切不到其它模式。
|
||||
this._modeLock = null;
|
||||
|
||||
if (elements.btnPreview) {
|
||||
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(用于持久化)。
|
||||
* @param {'preview'|'edit'|'split'} mode
|
||||
*/
|
||||
selectMode(mode) {
|
||||
if (!EDITOR_MODES.includes(mode)) return;
|
||||
// 模式锁:拒绝一切「切到非锁定模式」的请求(按钮 / Ctrl+E / 状态栏 chip 都走这里)
|
||||
if (this._modeLock !== null && mode !== this._modeLock) return;
|
||||
if (this.getMode() === mode) return; // 同模式 → 不重复触发
|
||||
this._setDOM(mode);
|
||||
if (this.onChange) this.onChange(mode);
|
||||
@@ -39,11 +64,15 @@ export class EditorToolbar {
|
||||
/**
|
||||
* 同步 DOM 与按钮高亮,但**不**触发 onChange。
|
||||
* 用于「外部(持久化设置)改变了模式」的场景,避免循环。
|
||||
*
|
||||
* 模式锁生效时,setMode 会被吸到锁定值 —— 这是关键:applyEditorMode 被外部调用
|
||||
* (比如 AI 修复预览打开时其它地方误触发 applyEditorMode('preview'))也会被纠正回锁定模式。
|
||||
* @param {'preview'|'edit'|'split'} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
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
|
||||
* 触发 onChange 回调。
|
||||
*
|
||||
* 模式锁生效时,cycle 不切模式(仅做一次 DOM 对齐,防御 lock 期间其它路径改了 dataset)。
|
||||
*/
|
||||
cycle() {
|
||||
if (this._modeLock !== null) {
|
||||
if (this.getMode() !== this._modeLock) {
|
||||
this._setDOM(this._modeLock);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cur = this.getMode();
|
||||
const idx = EDITOR_MODES.indexOf(cur);
|
||||
const next = EDITOR_MODES[(idx + 1) % EDITOR_MODES.length];
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
* @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners]
|
||||
* 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。
|
||||
* 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),
|
||||
hitZone = null,
|
||||
windowListeners = null,
|
||||
getDragSign = null,
|
||||
} = opts;
|
||||
|
||||
if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素');
|
||||
@@ -119,6 +127,7 @@ export function createSplitter(opts) {
|
||||
let pointerId = null;
|
||||
let startCoord = 0;
|
||||
let startSize = 0;
|
||||
let dragSign = 1; // 当前拖动的 dCoord 符号;getDragSign 在 startDrag 时赋值
|
||||
|
||||
function coordOf(eventLike) {
|
||||
return eventLike[coordKey];
|
||||
@@ -130,6 +139,21 @@ export function createSplitter(opts) {
|
||||
handle.classList.add('is-dragging');
|
||||
startCoord = coord;
|
||||
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) {
|
||||
body.style.cursor = cursor;
|
||||
body.style.userSelect = 'none';
|
||||
@@ -138,7 +162,7 @@ export function createSplitter(opts) {
|
||||
|
||||
function applyDrag(coord) {
|
||||
const dCoord = coord - startCoord;
|
||||
setSize(clampSize(startSize + dCoord));
|
||||
setSize(clampSize(startSize + dragSign * dCoord));
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
|
||||
@@ -226,4 +226,121 @@ describe('EditorToolbar a11y', () => {
|
||||
it('EDIT_MODES 导出值与内部一致', () => {
|
||||
expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditorToolbar.setModeLock(AI 修复预览场景)', () => {
|
||||
it('锁到 edit:selectMode(\'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('锁到 edit:cycle 不切模式,只做一次 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 对齐到 edit(dataset 改写),但不触发 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('锁到 edit:setMode 强制对齐到 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
225
tests/unit/splitter.test.js
Normal 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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user