20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 弹层拖拽 / 缩放辅助(基于 Pointer Events,跨鼠标/触屏统一)。
|
||||
*
|
||||
* - bindDrag:让面板跟随手柄移动(如 Modal 标题栏),拖拽期间抑制文本选中
|
||||
* - bindResize:拖动手柄调整面板宽高(如右下角 / 边缘缩放手柄),带最小/最大尺寸约束
|
||||
*
|
||||
* 均为纯 DOM 辅助:接收元素参数,不依赖组件实例。
|
||||
* 拖拽 / 缩放通过修改面板的 transform / inline size 完成,不改变文档流布局。
|
||||
*/
|
||||
|
||||
function releaseUserSelect() {
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.webkitUserSelect = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动拖拽。
|
||||
*
|
||||
* @param {HTMLElement} panel 被移动的面板元素
|
||||
* @param {HTMLElement} handle 拖拽手柄元素(如标题栏 header)
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.constrain] 是否约束在视口内,默认 true
|
||||
* @param {number} [options.minVisible] 面板至少保留在视口内的像素数,默认 40
|
||||
* @returns {{ reset: () => void, destroy: () => void }}
|
||||
*/
|
||||
export function bindDrag(panel, handle, options = {}) {
|
||||
const { constrain = true, minVisible = 40 } = options
|
||||
if (!panel || !handle) return { reset: () => {}, destroy: () => {} }
|
||||
|
||||
// 面板当前累计偏移
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
// 本次拖拽按下时的指针位置
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
// 本次拖拽开始时的累计偏移(用于计算增量,避免重复累加导致乱跑)
|
||||
let startOffsetX = 0
|
||||
let startOffsetY = 0
|
||||
// 本次拖拽开始时的面板 rect(用于约束计算)
|
||||
let base = null
|
||||
let dragging = false
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (event.button !== 0) return
|
||||
// 手柄内的交互元素(按钮/链接/表单控件)不触发拖拽
|
||||
if (event.target.closest('button, a, input, textarea, select, [data-no-drag]')) return
|
||||
|
||||
dragging = true
|
||||
startX = event.clientX
|
||||
startY = event.clientY
|
||||
startOffsetX = offsetX
|
||||
startOffsetY = offsetY
|
||||
const rect = panel.getBoundingClientRect()
|
||||
base = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||
handle.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.webkitUserSelect = 'none'
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!dragging) return
|
||||
// 总偏移 = 本次拖拽起点偏移 + 指针相对按下位置的增量
|
||||
let x = startOffsetX + (event.clientX - startX)
|
||||
let y = startOffsetY + (event.clientY - startY)
|
||||
if (constrain) {
|
||||
// 面板最终位置 = 拖动起点 rect + 累计偏移,限制其至少保留 minVisible 在视口内
|
||||
const minLeft = minVisible - base.left
|
||||
const maxLeft = window.innerWidth - base.width - minVisible - base.left
|
||||
const minTop = minVisible - base.top
|
||||
const maxTop = window.innerHeight - base.height - minVisible - base.top
|
||||
// 面板大于视口时可自由移动该轴,不做反向约束
|
||||
if (minLeft <= maxLeft) x = Math.min(Math.max(x, minLeft), maxLeft)
|
||||
if (minTop <= maxTop) y = Math.min(Math.max(y, minTop), maxTop)
|
||||
}
|
||||
offsetX = x
|
||||
offsetY = y
|
||||
panel.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
}
|
||||
|
||||
function onPointerEnd() {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
releaseUserSelect()
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerdown', onPointerDown)
|
||||
handle.addEventListener('pointermove', onPointerMove)
|
||||
handle.addEventListener('pointerup', onPointerEnd)
|
||||
handle.addEventListener('pointercancel', onPointerEnd)
|
||||
|
||||
return {
|
||||
/** 复位面板到初始位置并结束进行中的拖拽 */
|
||||
reset() {
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
dragging = false
|
||||
panel.style.transform = ''
|
||||
releaseUserSelect()
|
||||
},
|
||||
/** 解绑全部监听,面板销毁前调用 */
|
||||
destroy() {
|
||||
dragging = false
|
||||
releaseUserSelect()
|
||||
handle.removeEventListener('pointerdown', onPointerDown)
|
||||
handle.removeEventListener('pointermove', onPointerMove)
|
||||
handle.removeEventListener('pointerup', onPointerEnd)
|
||||
handle.removeEventListener('pointercancel', onPointerEnd)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尺寸缩放拖拽。
|
||||
*
|
||||
* 手柄方向用字母组合表示:e 右 / w 左 / s 下 / n 上,如 'se'(右下角)、'e'(右侧边缘)。
|
||||
* 拖动时按方向增减面板宽高,结果通过 onResize 回调交给调用方应用。
|
||||
*
|
||||
* @param {HTMLElement} panel 被缩放的面板元素
|
||||
* @param {HTMLElement} handle 缩放手柄元素
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.direction] 拖拽方向,默认 'se'
|
||||
* @param {number} [options.minWidth] 最小宽度,默认 200
|
||||
* @param {number} [options.minHeight] 最小高度,默认 120
|
||||
* @param {number} [options.maxWidth] 最大宽度,默认视口宽度
|
||||
* @param {number} [options.maxHeight] 最大高度,默认视口高度
|
||||
* @param {(size: { width: number, height: number }) => void} [options.onResize] 尺寸变化回调
|
||||
* @returns {{ destroy: () => void }}
|
||||
*/
|
||||
export function bindResize(panel, handle, options = {}) {
|
||||
const {
|
||||
direction = 'se',
|
||||
minWidth = 200,
|
||||
minHeight = 120,
|
||||
maxWidth = window.innerWidth,
|
||||
maxHeight = window.innerHeight,
|
||||
onResize = () => {},
|
||||
} = options
|
||||
if (!panel || !handle) return { destroy: () => {} }
|
||||
|
||||
// 本次拖拽按下时的指针位置与面板起始尺寸
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let base = null
|
||||
let dragging = false
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (event.button !== 0) return
|
||||
dragging = true
|
||||
startX = event.clientX
|
||||
startY = event.clientY
|
||||
const rect = panel.getBoundingClientRect()
|
||||
base = { width: rect.width, height: rect.height }
|
||||
// 拖拽中标记:供样式在拖拽期间保持手柄高亮
|
||||
handle.classList.add('is-resizing')
|
||||
handle.setPointerCapture(event.pointerId)
|
||||
event.preventDefault()
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.webkitUserSelect = 'none'
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!dragging) return
|
||||
const dx = event.clientX - startX
|
||||
const dy = event.clientY - startY
|
||||
let width = base.width
|
||||
let height = base.height
|
||||
if (direction.includes('e')) width = base.width + dx
|
||||
if (direction.includes('w')) width = base.width - dx
|
||||
if (direction.includes('s')) height = base.height + dy
|
||||
if (direction.includes('n')) height = base.height - dy
|
||||
width = Math.min(Math.max(width, minWidth), maxWidth)
|
||||
height = Math.min(Math.max(height, minHeight), maxHeight)
|
||||
onResize({ width: Math.round(width), height: Math.round(height) })
|
||||
}
|
||||
|
||||
function onPointerEnd() {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
handle.classList.remove('is-resizing')
|
||||
releaseUserSelect()
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerdown', onPointerDown)
|
||||
handle.addEventListener('pointermove', onPointerMove)
|
||||
handle.addEventListener('pointerup', onPointerEnd)
|
||||
handle.addEventListener('pointercancel', onPointerEnd)
|
||||
|
||||
return {
|
||||
/** 解绑全部监听,面板销毁前调用 */
|
||||
destroy() {
|
||||
dragging = false
|
||||
handle.classList.remove('is-resizing')
|
||||
releaseUserSelect()
|
||||
handle.removeEventListener('pointerdown', onPointerDown)
|
||||
handle.removeEventListener('pointermove', onPointerMove)
|
||||
handle.removeEventListener('pointerup', onPointerEnd)
|
||||
handle.removeEventListener('pointercancel', onPointerEnd)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 弹窗焦点管理辅助函数。
|
||||
* 用于 Modal / Drawer 等对话框:打开时聚焦容器、Tab 键循环、关闭后还原焦点。
|
||||
* 纯 DOM 辅助函数,不读取组件状态。
|
||||
*/
|
||||
|
||||
// 可聚焦元素选择器:排除 disabled 与 tabindex=-1(隐藏元素靠 getClientRects 过滤)
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* 获取容器内当前可聚焦的元素(可见且未禁用)。
|
||||
* @param {HTMLElement} container
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
export function getFocusable(container) {
|
||||
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚焦容器内第一个可聚焦元素;没有时聚焦容器本身。
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export function focusFirst(container) {
|
||||
const focusable = getFocusable(container)
|
||||
if (focusable.length > 0) focusable[0].focus()
|
||||
else container.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab 焦点陷阱:焦点在容器首尾或容器外时循环回弹。
|
||||
* 在容器自身的 keydown 事件上调用。
|
||||
* @param {KeyboardEvent} event
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export function trapFocus(event, container) {
|
||||
if (event.key !== 'Tab') return
|
||||
const focusable = getFocusable(container)
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault()
|
||||
container.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
const active = document.activeElement
|
||||
if (event.shiftKey && (active === first || !container.contains(active))) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && (active === last || !container.contains(active))) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 浮层定位纯函数。
|
||||
* 输入触发器与浮层尺寸、期望方位、偏移量与视口,返回无碰撞的落点坐标。
|
||||
* 纯函数、无副作用,便于单测;后续若替换为第三方定位库,只需替换此文件。
|
||||
*/
|
||||
|
||||
// 可选方位:4 主方向 × 3 对齐
|
||||
export const PLACEMENTS = [
|
||||
'top',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'bottom',
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
'left',
|
||||
'left-start',
|
||||
'left-end',
|
||||
'right',
|
||||
'right-start',
|
||||
'right-end',
|
||||
]
|
||||
|
||||
// 视口安全边距(px)
|
||||
const MARGIN = 8
|
||||
|
||||
// 主方向相反表(用于 flip)
|
||||
const OPPOSITE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
|
||||
|
||||
function parsePlacement(placement) {
|
||||
const [base, alignment = 'center'] = placement.split('-')
|
||||
return { base, alignment }
|
||||
}
|
||||
|
||||
// 按 base + alignment 计算初始坐标
|
||||
function place(base, alignment, rect, size, offset) {
|
||||
let x = 0
|
||||
let y = 0
|
||||
if (base === 'top' || base === 'bottom') {
|
||||
x = align(alignment, rect.left, rect.width, size.width)
|
||||
y = base === 'top' ? rect.top - size.height - offset : rect.bottom + offset
|
||||
} else {
|
||||
y = align(alignment, rect.top, rect.height, size.height)
|
||||
x = base === 'left' ? rect.left - size.width - offset : rect.right + offset
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function align(alignment, start, size, floatSize) {
|
||||
if (alignment === 'start') return start
|
||||
if (alignment === 'end') return start + size - floatSize
|
||||
return start + (size - floatSize) / 2 // center
|
||||
}
|
||||
|
||||
function overflows(x, y, size, viewport) {
|
||||
return (
|
||||
x < MARGIN ||
|
||||
y < MARGIN ||
|
||||
x + size.width > viewport.width - MARGIN ||
|
||||
y + size.height > viewport.height - MARGIN
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.placement 期望方位
|
||||
* @param {DOMRect} options.triggerRect 触发器位置
|
||||
* @param {{width:number,height:number}} options.floatSize 浮层尺寸
|
||||
* @param {number} [options.offset=0] 与触发器间距
|
||||
* @param {{width:number,height:number}} options.viewport 视口尺寸
|
||||
* @returns {{x:number,y:number,placement:string}} 落点坐标与实际方位
|
||||
*/
|
||||
export function computePosition({ placement, triggerRect, floatSize, offset = 0, viewport }) {
|
||||
const { base, alignment } = parsePlacement(placement)
|
||||
let { x, y } = place(base, alignment, triggerRect, floatSize, offset)
|
||||
|
||||
// 主轴溢出则尝试翻转,翻转后仍放不下再保留原方向交给 shift 兜底
|
||||
let resolvedBase = base
|
||||
if (overflows(x, y, floatSize, viewport)) {
|
||||
const flipped = OPPOSITE[base]
|
||||
const flippedPos = place(flipped, alignment, triggerRect, floatSize, offset)
|
||||
if (!overflows(flippedPos.x, flippedPos.y, floatSize, viewport)) {
|
||||
resolvedBase = flipped
|
||||
x = flippedPos.x
|
||||
y = flippedPos.y
|
||||
}
|
||||
}
|
||||
|
||||
// shift:约束回视口内(含浮层比视口大的极端情况)
|
||||
x = clamp(x, MARGIN, viewport.width - floatSize.width - MARGIN)
|
||||
y = clamp(y, MARGIN, viewport.height - floatSize.height - MARGIN)
|
||||
|
||||
const resolvedPlacement = alignment === 'center' ? resolvedBase : `${resolvedBase}-${alignment}`
|
||||
return { x, y, placement: resolvedPlacement }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* body 滚动锁。
|
||||
* 弹窗(Modal / Drawer)打开时锁定页面滚动,关闭时恢复。
|
||||
* 支持多个弹窗同时打开:引用计数,最后一个关闭才真正恢复滚动。
|
||||
* 模块级计数属于全局副作用,所有弹窗实例共享。
|
||||
*/
|
||||
|
||||
let lockCount = 0
|
||||
let restoreStyle = null
|
||||
|
||||
/**
|
||||
* 锁定 body 滚动。首次锁定时记录原样式并补偿滚动条宽度,
|
||||
* 避免锁定瞬间内容横向跳动。
|
||||
*/
|
||||
export function lockScroll() {
|
||||
if (lockCount === 0) {
|
||||
const body = document.body
|
||||
restoreStyle = {
|
||||
overflow: body.style.overflow,
|
||||
paddingRight: body.style.paddingRight,
|
||||
}
|
||||
// 锁定后滚动条消失,用 padding 补偿原滚动条宽度防止内容位移
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||
if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`
|
||||
body.style.overflow = 'hidden'
|
||||
}
|
||||
lockCount++
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁 body 滚动。所有弹窗关闭后恢复原始样式。
|
||||
*/
|
||||
export function unlockScroll() {
|
||||
if (lockCount === 0) return
|
||||
lockCount--
|
||||
if (lockCount === 0 && restoreStyle) {
|
||||
document.body.style.overflow = restoreStyle.overflow
|
||||
document.body.style.paddingRight = restoreStyle.paddingRight
|
||||
restoreStyle = null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user