UNPKG

@skillnull/drage-js

Version:

拖拽元素到页面任意位置**支持平台:** 浏览器 · 微信小程序 · Node.js · React Native

743 lines (643 loc) 21.2 kB
/** * DrageJs - 跨平台拖拽库 * 支持:浏览器 / 微信小程序 / Node.js / React Native */ declare const wx: any declare const getCurrentPages: any declare const global: any declare const require: any export type DragePlatform = 'browser' | 'weixin' | 'node' | 'rn' export type DrageStorageType = 'local' | 'session' | 'none' export type DrageStorageOperation = 'get' | 'set' | 'remove' export interface DrageStyle { [key: string]: string | number } export interface DrageListenOptions { ref: HTMLElement | string | Record<string, any> | null style?: DrageStyle setStorage?: DrageStorageType platform?: DragePlatform } class DrageJs { [key: string]: any ref: HTMLElement | string | Record<string, any> | null platform: DragePlatform draggingFlag: boolean initX: number | undefined initY: number | undefined currentX: number currentY: number offsetX: number offsetY: number pageX: number | undefined pageY: number | undefined style: DrageStyle setStorage: DrageStorageType animationFrame: number | null clientW: number clientH: number _wxBindEvents: Record<string, (event: any) => void> | null constructor() { this.ref = null this.draggingFlag = false this.initX = undefined this.initY = undefined this.currentX = 0 this.currentY = 0 this.offsetX = 0 this.offsetY = 0 this.pageX = undefined this.pageY = undefined this.style = { position: "fixed", zIndex: '999999' } this.setStorage = 'local' this.animationFrame = null // 平台检测 this._detectPlatform() // 初始化平台适配器 this._initAdapter() } /** * 检测运行平台 */ _detectPlatform() { // 微信小程序 / 微信环境 if (typeof wx !== 'undefined' && typeof wx.getSystemInfoSync === 'function') { this.platform = 'weixin' } // React Native else if (typeof global !== 'undefined' && global.__DEV__) { // RN 的 __DEV__ 全局变量 try { const rn = require('react-native') if (rn.Platform) { this.platform = 'rn' this.Platform = rn.Platform this.Dimensions = rn.Dimensions } } catch (e) { this.platform = 'browser' } } // Node.js (无 window) else if (typeof window === 'undefined' || window === null) { this.platform = 'node' } // 浏览器 else { this.platform = 'browser' } } /** * 初始化平台适配器 */ _initAdapter() { switch (this.platform) { case 'browser': this._initBrowser() break case 'weixin': this._initWeixin() break case 'node': this._initNode() break case 'rn': this._initRN() break } } /* ==================== 浏览器适配器 ==================== */ _initBrowser() { this._getClientSize = () => ({ w: window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth, h: window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight }) this._getElementRect = (el) => el.getBoundingClientRect() this._setElementPosition = (el, x, y) => { el.style.transform = `translate3d(${x}px, ${y}px, 0)` } this._bindEvent = (el, type, handler, opts) => { el.addEventListener(type, handler, opts) } this._unbindEvent = (el, type, handler) => { el.removeEventListener(type, handler) } this._bindDocEvent = (type, handler, opts) => { document.addEventListener(type, handler, opts) } this._unbindDocEvent = (type, handler) => { document.removeEventListener(type, handler) } this._raf = (fn) => requestAnimationFrame(fn) this._cancelRaf = (id) => cancelAnimationFrame(id) this._storage = { get: (key) => { try { return localStorage.getItem(key) } catch(e) { return null } }, set: (key, val) => { try { localStorage.setItem(key, val) } catch(e) {} }, remove: (key) => { try { localStorage.removeItem(key) } catch(e) {} }, getSession: (key) => { try { return sessionStorage.getItem(key) } catch(e) { return null } }, setSession: (key, val) => { try { sessionStorage.setItem(key, val) } catch(e) {} }, removeSession: (key) => { try { sessionStorage.removeItem(key) } catch(e) {} } } this._onResize = () => { this.clientW = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth this.clientH = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight this.resetPosition() } window.addEventListener('resize', this._onResize) const size = this._getClientSize() this.clientW = size.w this.clientH = size.h } /* ==================== 微信小程序适配器 ==================== */ _initWeixin() { let pages = getCurrentPages() let currentPage = pages[pages.length - 1] let pageCtx = currentPage ? currentPage.$page : null this._getClientSize = () => { const sys = wx.getSystemInfoSync() return { w: sys.windowWidth, h: sys.windowHeight } } this._getElementRect = (selector) => new Promise((resolve) => { const query = wx.createSelectorQuery().in(pageCtx) query.select(selector).boundingClientRect((rect) => { if (rect) { resolve(rect) } else { resolve({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }) } }).exec() }) this._setElementPosition = (ctx, x, y) => { ctx.setData({ dragStyle: { transform: `translate3d(${x}px, ${y}px, 0)` } }) } this._bindEvent = (ctx, eventKey, handler) => { ctx[eventKey] = handler } this._unbindEvent = (ctx, eventKey) => { ctx[eventKey] = null } // 小程序没有 document,使用全局事件绑定 this._bindDocEvent = (type, handler) => { this._globalHandlers = this._globalHandlers || {} this._globalHandlers[type] = handler } this._unbindDocEvent = (type) => { if (this._globalHandlers) { this._globalHandlers[type] = null } } this._raf = (fn) => { // 小程序中没有 requestAnimationFrame,使用 setData 驱动 fn() } this._cancelRaf = (id) => {} this._storage = { get: (key) => { try { return wx.getStorageSync(key) } catch(e) { return null } }, set: (key, val) => { try { wx.setStorageSync(key, val) } catch(e) {} }, remove: (key) => { try { wx.removeStorageSync(key) } catch(e) {} }, getSession: (key) => { try { return wx.getStorageSync(key) } catch(e) { return null } }, setSession: (key, val) => { try { wx.setStorageSync(key, val) } catch(e) {} }, removeSession: (key) => { try { wx.removeStorageSync(key) } catch(e) {} } } this._onResize = () => { wx.onWindowResize((res) => { this.clientW = res.windowWidth this.clientH = res.windowHeight }) } const size = this._getClientSize() this.clientW = size.w this.clientH = size.h } /* ==================== Node.js 适配器 ==================== */ _initNode() { this._getClientSize = () => ({ w: 1920, h: 1080 }) this._getElementRect = (el) => ({ top: el._top || 0, bottom: (el._top || 0) + (el._height || 0), left: el._left || 0, right: (el._left || 0) + (el._width || 0) }) this._setElementPosition = (el, x, y) => { el._x = x el._y = y el.style = el.style || {} el.style.transform = `translate3d(${x}px, ${y}px, 0)` } this._bindEvent = () => {} this._unbindEvent = () => {} this._bindDocEvent = () => {} this._unbindDocEvent = () => {} this._raf = (fn) => { fn() } this._cancelRaf = () => {} this._storage = { get: () => null, set: () => {}, remove: () => {}, getSession: () => null, setSession: () => {}, removeSession: () => {} } this._onResize = () => {} const size = this._getClientSize() this.clientW = size.w this.clientH = size.h } /* ==================== React Native 适配器 ==================== */ _initRN() { let dimSubscription = null this._getClientSize = () => { try { const dim = this.Dimensions.get('window') return { w: dim.width, h: dim.height } } catch (e) { return { w: 375, h: 667 } } } this._getElementRect = (ref) => new Promise((resolve) => { try { if (ref && ref.measure) { ref.measure((fx, fy, width, height, px, py) => { resolve({ top: py, bottom: py + height, left: px, right: px + width }) }) } else { resolve({ top: 0, bottom: 0, left: 0, right: 0 }) } } catch (e) { resolve({ top: 0, bottom: 0, left: 0, right: 0 }) } }) // RN 中使用 left/top 代替 transform this._setElementPosition = (ref, x, y) => { try { if (ref && ref.setNativeProps) { ref.setNativeProps({ style: { left: x, top: y } }) } } catch (e) {} } this._bindEvent = (ref, handler) => { try { if (ref && ref.attachRef) { ref.attachRef('drageRef', handler) } } catch (e) {} } this._unbindEvent = () => {} this._bindDocEvent = () => {} this._unbindDocEvent = () => {} this._raf = (fn) => { fn() } this._cancelRaf = () => {} this._storage = { get: (key) => { try { const AsyncStorage = require('@react-native-async-storage/async-storage') return AsyncStorage.getItem(key) } catch (e) { return null } }, set: (key, val) => { try { const AsyncStorage = require('@react-native-async-storage/async-storage') AsyncStorage.setItem(key, val) } catch (e) {} }, remove: (key) => { try { const AsyncStorage = require('@react-native-async-storage/async-storage') AsyncStorage.removeItem(key) } catch (e) {} }, getSession: (key) => { try { const AsyncStorage = require('@react-native-async-storage/async-storage') return AsyncStorage.getItem(key) } catch (e) { return null } }, setSession: (key, val) => { try { const AsyncStorage = require('@react-native-async-storage/async-storage') AsyncStorage.setItem(key, val) } catch (e) {} }, removeSession: (key) => { try { const AsyncStorage = require('@react-native-async-storage/async-storage') AsyncStorage.removeItem(key) } catch (e) {} } } this._onResize = () => { try { dimSubscription = this.Dimensions.addEventListener('change', (size) => { this.clientW = size.window.width this.clientH = size.window.height }) } catch (e) {} } const size = this._getClientSize() this.clientW = size.w this.clientH = size.h } /* ==================== 存储适配 ==================== */ _getStorage(type) { const s = this._storage if (type === 'local') { return { get: s.get, set: s.set, remove: s.remove } } return { get: s.getSession, set: s.setSession, remove: s.removeSession } } storageHandle(operation: DrageStorageOperation) { if (operation === 'get') { const store = this._getStorage(this.setStorage) let storage = store.get('DrageJS_INFO') try { storage = JSON.parse(storage) if (!storage) return this.currentX = storage.currentX || 0 this.initX = storage.currentX || 0 this.offsetX = storage.currentX || 0 this.currentY = storage.currentY || 0 this.initY = storage.currentY || 0 this.offsetY = storage.currentY || 0 this.setRef() } catch (err) { // silent } } if (operation === 'set') { const store = this._getStorage(this.setStorage) store.set('DrageJS_INFO', JSON.stringify({ currentX: this.currentX, currentY: this.currentY })) } if (operation === 'remove') { const storeLocal = this._getStorage('local') const storeSession = this._getStorage('session') storeLocal.remove('DrageJS_INFO') storeSession.remove('DrageJS_INFO') } } resetPosition() { this.currentX = 0 this.currentY = 0 this.initX = 0 this.initY = 0 this.offsetX = 0 this.offsetY = 0 this.storageHandle('remove') this.setRef() } /* ==================== 公共接口 ==================== */ /** * 启用拖拽 * @param {Object} options * @param {HTMLElement|String|Function} options.ref - 浏览器: HTMLElement / 小程序: 选择器字符串 / RN: ref 回调 * @param {Object} [options.style={}] - 自定义样式 * @param {String} [options.setStorage='local'] - 'local' | 'session' | 'none' * @param {String} [options.platform] - 强制指定平台: 'browser' | 'weixin' | 'node' | 'rn' */ listen({ref, style = {}, setStorage = 'local', platform}: DrageListenOptions) { // 允许运行时覆盖平台 if (platform && platform !== this.platform) { this.platform = platform this._initAdapter() } this.ref = ref this.setStorage = setStorage if (style && Object.keys(style).length > 0) { this.style = style } if (setStorage && setStorage !== 'none') { this.storageHandle('get') } // 根据平台绑定事件 switch (this.platform) { case 'browser': this._listenBrowser(ref) break case 'weixin': this._listenWeixin(ref) break case 'node': this._listenNode(ref) break case 'rn': this._listenRN(ref) break } } /** * 移除拖拽监听 * @param {HTMLElement|String|Function} [ref] */ removeListen(ref?: DrageListenOptions['ref']) { this.draggingFlag = false switch (this.platform) { case 'browser': this._removeBrowser(ref) break case 'weixin': this._removeWeixin(ref) break case 'node': // Node 无需清理 break case 'rn': this._removeRN(ref) break } if (this._onResize) { if (typeof window !== 'undefined' && window.removeEventListener) { window.removeEventListener('resize', this._onResize) } } if (this.platform === 'rn' && this._dimSubscription) { try { this._dimSubscription.remove() } catch(e) {} } } setRef() { if (!this.ref) return // 应用用户自定义样式 if (this.platform === 'browser' && typeof this.ref !== 'string') { for (let item in this.style) { this.ref.style[item] = this.style[item] } } this._setElementPosition(this.ref, this.currentX, this.currentY) } /* ==================== 浏览器事件绑定 ==================== */ _listenBrowser(ref) { if (!ref) return ref.setAttribute('draggable', false) this._onTouchStart = (e) => this.onStart(e) this._onMouseDown = (e) => this.onStart(e) this._onTouchEnd = (e) => this.onEnd(e) this._onMouseUp = (e) => this.onEnd(e) this._onTouchMove = (e) => this.onMove(e) this._onMouseMove = (e) => this.onMove(e) this._bindEvent(ref, 'touchstart', this._onTouchStart, { passive: false }) this._bindEvent(ref, 'mousedown', this._onMouseDown, { passive: false }) this._bindDocEvent('touchend', this._onTouchEnd) this._bindDocEvent('mouseup', this._onMouseUp) } _removeBrowser(ref) { const target = ref || this.ref || document if (!target) return if (this._onTouchStart) target.removeEventListener('touchstart', this._onTouchStart) if (this._onMouseDown) target.removeEventListener('mousedown', this._onMouseDown) if (this._onTouchEnd) document.removeEventListener('touchend', this._onTouchEnd) if (this._onMouseUp) document.removeEventListener('mouseup', this._onMouseUp) if (this._onTouchMove) document.removeEventListener('touchmove', this._onTouchMove) if (this._onMouseMove) document.removeEventListener('mousemove', this._onMouseMove) } /* ==================== 微信小程序事件绑定 ==================== */ _listenWeixin(selector) { this._refSelector = selector // 小程序中通过 bind:touchstart 等绑定到页面上 // 这里提供一个方法让调用方在 WXML 中绑定 this._handlers = { touchstart: (e) => this.onStart(e), touchmove: (e) => this.onMove(e), touchend: (e) => this.onEnd(e) } // 存储 handler 供外部在 WXML 中绑定 this._wxBindEvents = { onTouchstart: (e) => this._handlers.touchstart(e), onTouchmove: (e) => this._handlers.touchmove(e), onTouchend: (e) => this._handlers.touchend(e) } } _removeWeixin(ref?: DrageListenOptions['ref']) { this._wxBindEvents = null this._handlers = null } /* ==================== Node.js 事件绑定 ==================== */ _listenNode(ref) { // Node 环境下无真实 DOM,仅做内存模拟 if (ref) { ref._top = ref._top || 0 ref._left = ref._left || 0 ref._width = ref._width || 100 ref._height = ref._height || 100 } } /* ==================== React Native 事件绑定 ==================== */ _listenRN(ref) { this._rnRef = ref } _removeRN(ref?: DrageListenOptions['ref']) { this._rnRef = null } /* ==================== 拖拽核心逻辑 ==================== */ onStart(event: any) { this.draggingFlag = true let clientX, clientY if (this.platform === 'browser') { const touch = event.touches ? event.touches[0] : (event.changedTouches ? event.changedTouches[0] : event) clientX = touch.clientX clientY = touch.clientY } else if (this.platform === 'weixin') { clientX = event.touches ? event.touches[0].clientX : 0 clientY = event.touches ? event.touches[0].clientY : 0 } else { clientX = event.clientX || 0 clientY = event.clientY || 0 } this.initX = clientX - this.offsetX this.initY = clientY - this.offsetY // 绑定移动/结束事件 if (this.platform === 'browser') { this._bindDocEvent('touchmove', this._onTouchMove, { passive: false }) this._bindDocEvent('mousemove', this._onMouseMove, { passive: false }) } else if (this.platform === 'weixin') { // 小程序事件已在 WXML 中绑定,不需要额外操作 } } onMove(event: any) { if (!this.draggingFlag || !event) return let clientX, clientY if (this.platform === 'browser') { const touch = event.touches ? event.touches[0] : (event.changedTouches ? event.changedTouches[0] : event) clientX = touch.clientX clientY = touch.clientY } else if (this.platform === 'weixin') { clientX = event.touches ? event.touches[0].clientX : 0 clientY = event.touches ? event.touches[0].clientY : 0 } else { clientX = event.clientX || 0 clientY = event.clientY || 0 } this.currentX = clientX - this.initX this.currentY = clientY - this.initY const moveX = this.currentX - this.offsetX const moveY = this.currentY - this.offsetY // 获取元素位置信息 let rect if (this.platform === 'browser') { rect = this._getElementRect(this.ref) } else if (this.platform === 'weixin') { // 小程序异步获取,这里用缓存值 rect = this._cachedRect || { top: 0, bottom: 0, left: 0, right: 0 } } else { rect = this._getElementRect(this.ref) } const { top, bottom, left, right } = rect // 边界处理 - 左/上 if (left <= 0) { if (moveX <= 0) { this.offsetX -= left this.currentX = this.offsetX } } else if (right >= this.clientW) { if (moveX >= 0) { this.offsetX -= (right - this.clientW) this.currentX = this.offsetX } } else { this.offsetX = this.currentX } if (top <= 0) { if (moveY <= 0) { this.offsetY -= top this.currentY = this.offsetY } } else if (bottom >= this.clientH) { if (moveY >= 0) { this.offsetY -= (bottom - this.clientH) this.currentY = this.offsetY } } else { this.offsetY = this.currentY } // 缓存矩形用于小程序 if (this.platform === 'weixin') { this._cachedRect = rect } this._raf(() => { this.setRef() }) } onEnd(event: any) { this.draggingFlag = false if (this.platform === 'browser') { this._unbindDocEvent('touchmove', this._onTouchMove) this._unbindDocEvent('mousemove', this._onMouseMove) } this.storageHandle('set') } } /* ==================== 多环境导出 ==================== */ const Drage = new DrageJs() // 浏览器 UMD 导出 if (typeof window !== 'undefined' && window !== null) { ;(window as any).Drage = Drage } // ES Module 导出 export default Drage