update
This commit is contained in:
260
src/splitter.js
Normal file
260
src/splitter.js
Normal file
@@ -0,0 +1,260 @@
|
||||
// 通用拖拽分割条工厂
|
||||
// ============================================================================
|
||||
//
|
||||
// 起源(Stage 4d 抽出):src/app.js 里有三段几乎一模一样的 mountSplitter*:
|
||||
// - mountSplitter (editor ↔ viewer,3480-3640)
|
||||
// - mountSidebarSplitter (sidebar,3652-3850)
|
||||
// - mountAiSplitter (AI panel,3865-3984)
|
||||
//
|
||||
// 三段共享的样板:
|
||||
// - 鼠标 + 触屏 drag 状态机(dragging / pointerId / startX / startWidth)
|
||||
// - cursor: col-resize + userSelect: none 切换
|
||||
// - is-dragging class 切换
|
||||
// - 命中区扩展(仅 sidebar / AI:±HIT_RADIUS 像素也算命中)
|
||||
// - document 级 mousemove / touchmove / mouseup / touchend / touchcancel
|
||||
//
|
||||
// 不共享的部分(由调用方注入):
|
||||
// - 边界 clamp:editor 走 frSpace 动态算,sidebar/AI 走静态 [MIN, MAX]
|
||||
// - 持久化:splitRatio / sidebarWidth / aiWidth 三个字段
|
||||
// - 命中区距离计算:sidebar 缓存中线(Phase M-H6),AI 每次重读
|
||||
//
|
||||
// 设计:
|
||||
// - 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 解除挂载的所有 listener(document 级 + 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 一致
|
||||
|
||||
/** 当前挂的所有 listener,dispose 时一次性解除 */
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user