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

@@ -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 之后才能实例化)

View File

@@ -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());

View File

@@ -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];

View File

@@ -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() {