Files
Notes/src/splitter.js
2026-09-12 14:15:26 +08:00

260 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 通用拖拽分割条工厂
// ============================================================================
//
// 起源Stage 4d 抽出src/app.js 里有三段几乎一模一样的 mountSplitter*
// - mountSplitter (editor ↔ viewer3480-3640)
// - mountSidebarSplitter (sidebar3652-3850)
// - mountAiSplitter (AI panel3865-3984)
//
// 三段共享的样板:
// - 鼠标 + 触屏 drag 状态机dragging / pointerId / startX / startWidth
// - cursor: col-resize + userSelect: none 切换
// - is-dragging class 切换
// - 命中区扩展(仅 sidebar / AI±HIT_RADIUS 像素也算命中)
// - document 级 mousemove / touchmove / mouseup / touchend / touchcancel
//
// 不共享的部分(由调用方注入):
// - 边界 clampeditor 走 frSpace 动态算sidebar/AI 走静态 [MIN, MAX]
// - 持久化splitRatio / sidebarWidth / aiWidth 三个字段
// - 命中区距离计算sidebar 缓存中线Phase M-H6AI 每次重读
//
// 设计:
// - createSplitter(opts) 接受 handle / setSize / getSize / clampSize / onDragEnd
// 等回调;工厂不耦合业务(不知道是 px 还是 ratio不知道写到 :root 还是
// .appShell保持单一职责。
// - 内部维护自己挂的 listener 列表dispose() 一次性解除 —— 调用方在 pagehide
// 兜底调一次,避免 15 个 document 级 listener 长期持有闭包。
// ============================================================================
/**
* @typedef {Object} HitZoneOpts
* @property {number} radius - 鼠标距 splitter 中线 ± radius 像素都算命中
* @property {() => boolean} [isAvailable] - 当前是否启用命中区AI 关闭时返回 false
* 默认 true
* @property {(coord: number) => number} distanceTo - 鼠标坐标 → 到 splitter 中线的距离
* sidebar 缓存中线版本由调用方实现,工厂不关心怎么算)
* @property {(inZone: boolean) => void} [onHover] - 进/出命中区的视觉反馈
* 默认:改 document.body.style.cursor仅 axis='x' → col-resize
*/
/**
* @typedef {Object} SplitterOpts
* @property {HTMLElement} handle - 拖拽条元素
* @property {'x' | 'y'} [axis='x'] - 'x' = 水平拖动改宽度;'y' = 垂直拖动改高度
* @property {() => number} getSize - 当前目标元素的尺寸px
* 拖动期间会读多次(每次 mousemove 都会读mount 时也会读一次确认初值
* @property {(px: number) => void} setSize - 把 px 写到目标CSS 变量 / inline style
* 调用方负责写哪儿(:root / .appShell / 元素自身)
* @property {(px: number) => number} clampSize - clamp 到合法范围(动态边界由调用方负责)
* @property {(px: number) => void} onDragEnd - 拖完调一次,参数是已 clamp 的 px
* 调用方负责持久化 / 反算 ratio / 弹错误提示
* @property {() => number} [initialSize] - 启动初始化。mount 时会用 setSize 应用一次
* @property {HTMLElement} [body] - 设 cursor/userSelect 的元素,默认 document.body
* @property {HitZoneOpts} [hitZone] - 扩展命中区(不传 = 不启用)
* @property {Array<{type: string, listener: Function, options?: AddEventListenerOptions|boolean}>} [windowListeners]
* 额外挂在 window 上的监听器(如 sidebar 的 resize/scroll 用来失效命中区中线缓存)。
* dispose() 时一并解除 —— 与 handle / document 监听器同走同一张表,单点真理。
*/
/**
* 创建通用拖拽分割条。
*
* 鼠标 / 触屏 拖拽的语义与原三段实现一致:
* - mouse / touch 都能起拖;触屏用 touch.identifier 关联 touchmove/touchend
* - 拖动期间document.body.style.cursor = 'col-resize' 或 'row-resize'
* userSelect = 'none';拖完恢复
* - 拖完一次:调 onDragEnd(clampSize(getSize())),由调用方持久化
*
* 命中区扩展hitZone语义
* - document 级 capture mousedown鼠标在 ±radius 内且目标不是 handle 自己,
* 启动拖动capture 阶段确保在 file-list / editor 等之前拿到事件)
* - document 级 mousemove拖动中按 axis 改尺寸;空闲时按 distanceTo 算 inZone
* 通知 onHover 做视觉反馈(默认 cursor
* - 调用方传 isAvailable 返回 false 时整个命中区逻辑跳过AI 关闭时)
*
* @param {SplitterOpts} opts
* @returns {{
* dispose: () => void,
* rebalance?: () => void,
* }}
* dispose 解除挂载的所有 listenerdocument 级 + handle 级 + window 级)
* rebalance 仅当传了 initialSize 时返回 —— 调用方在 window resize / layout 变化
* 时调,按最新 frSpace / DPI 重算尺寸(不影响持久化值)
*/
export function createSplitter(opts) {
const {
handle,
axis = 'x',
getSize,
setSize,
clampSize,
onDragEnd,
initialSize,
body = (typeof document !== 'undefined' ? document.body : null),
hitZone = null,
windowListeners = null,
} = opts;
if (!handle) throw new Error('[splitter] handle 必须是 DOM 元素');
if (typeof getSize !== 'function') throw new Error('[splitter] getSize 必须是函数');
if (typeof setSize !== 'function') throw new Error('[splitter] setSize 必须是函数');
if (typeof clampSize !== 'function') throw new Error('[splitter] clampSize 必须是函数');
if (typeof onDragEnd !== 'function') throw new Error('[splitter] onDragEnd 必须是函数');
const cursor = axis === 'x' ? 'col-resize' : 'row-resize';
// axis 对应的 clientX/Y 字段名
const coordKey = axis === 'x' ? 'clientX' : 'clientY';
// 触屏坐标取 touches[i[]. 的 clientX/Y命名与 coordKey 一致
/** 当前挂的所有 listenerdispose 时一次性解除 */
const listeners = [];
function addListener(target, type, listener, options) {
target.addEventListener(type, listener, options);
listeners.push({ target, type, listener, options });
}
// 拖拽状态机 —— 与原三段实现语义一致
let dragging = false;
let pointerId = null;
let startCoord = 0;
let startSize = 0;
function coordOf(eventLike) {
return eventLike[coordKey];
}
function startDrag(coord, id) {
dragging = true;
pointerId = id;
handle.classList.add('is-dragging');
startCoord = coord;
startSize = getSize();
if (body) {
body.style.cursor = cursor;
body.style.userSelect = 'none';
}
}
function applyDrag(coord) {
const dCoord = coord - startCoord;
setSize(clampSize(startSize + dCoord));
}
function endDrag() {
if (!dragging) return;
dragging = false;
pointerId = null;
handle.classList.remove('is-dragging');
if (body) {
body.style.cursor = '';
body.style.userSelect = '';
}
onDragEnd(clampSize(getSize()));
}
// ----- 自身 handle 上的 mousedown / touchstart -----
addListener(handle, 'mousedown', (e) => {
startDrag(coordOf(e), null);
e.preventDefault();
});
addListener(handle, 'touchstart', (e) => {
const t = e.touches[0];
if (!t) return;
startDrag(coordOf(t), t.identifier);
}, { passive: true });
// ----- document 级 mousemove / touchmove / ----end -----
addListener(document, 'mousemove', (e) => {
if (!dragging || pointerId !== null) return;
applyDrag(coordOf(e));
});
addListener(document, 'touchmove', (e) => {
if (!dragging || pointerId === null) return;
const t = Array.from(e.touches).find((x) => x.identifier === pointerId);
if (!t) return;
applyDrag(coordOf(t));
}, { passive: true });
addListener(document, 'mouseup', endDrag);
addListener(document, 'touchend', endDrag);
addListener(document, 'touchcancel', endDrag);
// ----- 命中区扩展(可选)-----
if (hitZone) {
const { radius, isAvailable = () => true, distanceTo, onHover } = hitZone;
// document 级 capture mousedown捕获阶段拿到事件避免被 file-list 等吞掉
addListener(document, 'mousedown', (e) => {
if (dragging) return;
if (e.button !== 0) return; // 只接左键
if (!isAvailable()) return;
// handle 自己 / handle 内子节点(含 reset 按钮等)不重入 —— 子节点自己
// 处理 mousedown一般带 stopPropagation否则会出现「点了按钮又拖动」。
if (handle.contains(e.target)) return;
if (distanceTo(coordOf(e)) > radius) return;
e.preventDefault();
e.stopPropagation();
startDrag(coordOf(e), null);
}, true);
// 拖动中 + 空闲命中区视觉反馈(合并到一个监听器,少派发一次)
let lastHover = null;
addListener(document, 'mousemove', (e) => {
if (dragging) {
if (pointerId === null) applyDrag(coordOf(e));
return;
}
if (!isAvailable()) return;
const inZone = distanceTo(coordOf(e)) <= radius;
if (inZone !== lastHover) {
lastHover = inZone;
if (onHover) onHover(inZone);
else if (body) body.style.cursor = inZone ? cursor : '';
}
});
}
// ----- 扩展 window 监听器(可选)-----
// 例如 sidebar splitter 用 resize / scroll 失效命中区中线缓存。
// 走 addListener 同一张表dispose() 一次性解除。
if (windowListeners && windowListeners.length) {
for (const { type, listener, options } of windowListeners) {
addListener(window, type, listener, options);
}
}
// ----- 启动初始化 -----
if (typeof initialSize === 'function') {
setSize(clampSize(initialSize()));
}
// ----- 返回 -----
const result = {
/** 解除所有挂载的 listener。可重复调用。 */
dispose() {
for (const { target, type, listener, options } of listeners) {
try { target.removeEventListener(type, listener, options); } catch { /* ignore */ }
}
listeners.length = 0;
if (body) {
body.style.cursor = '';
body.style.userSelect = '';
}
handle.classList.remove('is-dragging');
},
};
// 仅当调用方传 initialSize 时才暴露 rebalance —— 让"窗口 resize 时按 frSpace 重算"
// 这类外部触发走单点真理(避免多个 mountSplitter 都各自挂 resize 监听器)。
if (typeof initialSize === 'function') {
result.rebalance = () => {
if (dragging) return;
setSize(clampSize(initialSize()));
};
}
return result;
}