├── .gitignore ├── .npmrc ├── .npmignore ├── doc ├── 屏幕录制2024-09-10 14.28.10 (1).gif └── README_EN.md ├── src ├── dragv1 │ ├── index.ts │ ├── Draggable.js │ └── Draggable.ts ├── drag │ ├── index.ts │ ├── types.ts │ ├── utils.ts │ ├── createDraggable.ts │ ├── draggable.ts │ └── mobileDraggable.ts ├── App.vue ├── stores │ └── userTrackerStore.ts ├── styles │ └── reset.css └── components │ └── DraggableExample.vue ├── lib ├── types │ ├── utils.d.ts │ ├── createDraggable.d.ts │ ├── index.d.ts │ ├── types.d.ts │ ├── draggable.d.ts │ └── mobileDraggable.d.ts ├── drag-kit.umd.js └── drag-kit.mjs ├── main.ts ├── index.html ├── tsconfig.json ├── LICENSE ├── vite.config.ts ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .history/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry="https://registry.npmmirror.com" -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .vscode 4 | **/*.test.ts 5 | -------------------------------------------------------------------------------- /doc/屏幕录制2024-09-10 14.28.10 (1).gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SailingCoder/drag-kit/HEAD/doc/屏幕录制2024-09-10 14.28.10 (1).gif -------------------------------------------------------------------------------- /src/dragv1/index.ts: -------------------------------------------------------------------------------- 1 | import { DraggableOptions, createDraggable } from './Draggable'; 2 | 3 | export { 4 | createDraggable 5 | } 6 | 7 | export type { 8 | DraggableOptions 9 | } 10 | -------------------------------------------------------------------------------- /lib/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | export declare function savePosition(element: HTMLElement, shouldSave: boolean): void; 2 | export declare function restorePosition(element: HTMLElement, shouldSave: boolean, options: any): void; 3 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import { createPinia } from 'pinia'; 3 | import App from './src/App.vue'; 4 | 5 | 6 | const app = createApp(App); 7 | const pinia = createPinia(); 8 | app.use(pinia) 9 | app.mount('#app'); 10 | -------------------------------------------------------------------------------- /lib/types/createDraggable.d.ts: -------------------------------------------------------------------------------- 1 | import { Draggable } from './draggable'; 2 | import { MobileDraggable } from './mobileDraggable'; 3 | import { DraggableOptions } from './types'; 4 | export declare function createDraggable(elementId: string, options?: DraggableOptions): Draggable | MobileDraggable | null; 5 | -------------------------------------------------------------------------------- /lib/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { createDraggable } from './createDraggable'; 2 | import { DraggableOptions } from './types'; 3 | import { Draggable } from './draggable'; 4 | import { MobileDraggable } from './mobileDraggable'; 5 | export { createDraggable, Draggable, MobileDraggable }; 6 | export type { DraggableOptions }; 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vue3 Draggable Examples 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/drag/index.ts: -------------------------------------------------------------------------------- 1 | import { createDraggable } from './createDraggable'; 2 | import { DraggableOptions } from './types'; 3 | import { Draggable } from './draggable'; 4 | import { MobileDraggable } from './mobileDraggable'; 5 | 6 | export { 7 | createDraggable, 8 | Draggable, 9 | MobileDraggable 10 | } 11 | 12 | export type { 13 | DraggableOptions 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "declaration": true, 7 | "declarationDir": "./lib/types", 8 | "rootDir": "./src/drag", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": ["./src/drag/**/*"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /src/stores/userTrackerStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | export const useUserTrackerStore = defineStore('userTracker', () => { 5 | const userTrackerData = ref({ action: '', type: '', data: {} }); 6 | 7 | function updateTrackerData(action: string, type: string, data: any) { 8 | userTrackerData.value = { action, type, data }; 9 | } 10 | 11 | return { userTrackerData, updateTrackerData }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/drag/types.ts: -------------------------------------------------------------------------------- 1 | export interface DraggableOptions { 2 | initialPosition?: { x?: string; y?: string }; 3 | shouldSave?: boolean; 4 | onDragStart?: (element: HTMLElement) => void; 5 | onDrag?: (element: HTMLElement) => void; 6 | onDragEnd?: (element: HTMLElement) => void; 7 | dragArea?: HTMLElement; 8 | lockAxis?: 'x' | 'y'; 9 | edgeBuffer?: number; 10 | gridSize?: number; 11 | mode?: 'screen' | 'page' | 'container'; 12 | snapMode?: 'none' | 'auto' | 'right' | 'left' | 'top' | 'bottom'; 13 | } 14 | -------------------------------------------------------------------------------- /lib/types/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface DraggableOptions { 2 | initialPosition?: { 3 | x?: string; 4 | y?: string; 5 | }; 6 | shouldSave?: boolean; 7 | onDragStart?: (element: HTMLElement) => void; 8 | onDrag?: (element: HTMLElement) => void; 9 | onDragEnd?: (element: HTMLElement) => void; 10 | dragArea?: HTMLElement; 11 | lockAxis?: 'x' | 'y'; 12 | edgeBuffer?: number; 13 | gridSize?: number; 14 | mode?: 'screen' | 'page' | 'container'; 15 | snapMode?: 'none' | 'auto' | 'right' | 'left' | 'top' | 'bottom'; 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sailing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/types/draggable.d.ts: -------------------------------------------------------------------------------- 1 | import { DraggableOptions } from './types'; 2 | export declare class Draggable { 3 | element: HTMLElement; 4 | shouldSave: boolean; 5 | minX: number; 6 | minY: number; 7 | maxX: number; 8 | maxY: number; 9 | diffX: number; 10 | diffY: number; 11 | mouseMoveHandler: ((event: MouseEvent) => void) | null; 12 | mouseUpHandler: ((event: MouseEvent) => void) | null; 13 | observerElement: MutationObserver | null; 14 | config: DraggableOptions; 15 | initialPosition: { 16 | x: string; 17 | y: string; 18 | }; 19 | startX: number; 20 | startY: number; 21 | dragThreshold: number; 22 | constructor(element: HTMLElement, options?: DraggableOptions); 23 | static defaultOptions: DraggableOptions; 24 | updateBounds(): void; 25 | onMouseDown(event: MouseEvent): void; 26 | onMouseMove(event: MouseEvent): void; 27 | stopPreventEvent(event: MouseEvent): void; 28 | disableClickEvent(): void; 29 | onMouseUp(event: any): void; 30 | savePosition(): void; 31 | restorePosition(): void; 32 | observeElementVisibility(element: HTMLElement): void; 33 | init(): void; 34 | destroy(): void; 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* CSS Reset */ 2 | html, body, div, span, applet, object, iframe, 3 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 4 | a, abbr, acronym, address, big, cite, code, 5 | del, dfn, em, img, ins, kbd, q, s, samp, 6 | small, strike, strong, sub, sup, tt, var, 7 | b, u, i, center, 8 | dl, dt, dd, ol, ul, li, 9 | fieldset, form, label, legend, 10 | table, caption, tbody, tfoot, thead, tr, th, td, 11 | article, aside, canvas, details, embed, 12 | figure, figcaption, footer, header, hgroup, 13 | menu, nav, output, ruby, section, summary, 14 | time, mark, audio, video { 15 | margin: 0; 16 | padding: 0; 17 | border: 0; 18 | /* font-size: 100%; */ 19 | /* font: inherit; */ 20 | /* vertical-align: baseline; */ 21 | } 22 | /* HTML5 display-role reset for older browsers */ 23 | article, aside, details, figcaption, figure, 24 | footer, header, hgroup, menu, nav, section { 25 | display: block; 26 | } 27 | body { 28 | line-height: 1; 29 | } 30 | ol, ul { 31 | list-style: none; 32 | } 33 | blockquote, q { 34 | quotes: none; 35 | } 36 | blockquote:before, blockquote:after, 37 | q:before, q:after { 38 | content: ''; 39 | content: none; 40 | } 41 | table { 42 | border-collapse: collapse; 43 | border-spacing: 0; 44 | } 45 | -------------------------------------------------------------------------------- /lib/types/mobileDraggable.d.ts: -------------------------------------------------------------------------------- 1 | import { DraggableOptions } from './types'; 2 | export declare class MobileDraggable { 3 | element: HTMLElement; 4 | shouldSave: boolean; 5 | minX: number; 6 | minY: number; 7 | maxX: number; 8 | maxY: number; 9 | diffX: number; 10 | diffY: number; 11 | touchMoveHandler: ((event: TouchEvent) => void) | null; 12 | touchEndHandler: ((event: TouchEvent) => void) | null; 13 | observerElement: MutationObserver | null; 14 | config: DraggableOptions; 15 | initialPosition: { 16 | x: string; 17 | y: string; 18 | }; 19 | startX: number; 20 | startY: number; 21 | dragThreshold: number; 22 | isDragging: boolean; 23 | constructor(element: HTMLElement, options?: DraggableOptions); 24 | static defaultOptions: DraggableOptions; 25 | private getEventCoordinates; 26 | updateBounds(): void; 27 | onTouchStart(event: TouchEvent): void; 28 | onTouchMove(event: TouchEvent): void; 29 | stopPreventEvent(event: TouchEvent): void; 30 | disableClickEvent(): void; 31 | onTouchEnd(event: TouchEvent): void; 32 | savePosition(): void; 33 | restorePosition(): void; 34 | observeElementVisibility(element: HTMLElement): void; 35 | static isMobileDevice(): boolean; 36 | init(): void; 37 | destroy(): void; 38 | } 39 | -------------------------------------------------------------------------------- /src/drag/utils.ts: -------------------------------------------------------------------------------- 1 | const StorageKey = 'SailingDraggablePositions'; 2 | 3 | export function savePosition(element: HTMLElement, shouldSave: boolean): void { 4 | if (!shouldSave) return; 5 | 6 | const savedPositions = JSON.parse(localStorage.getItem(StorageKey) || '{}'); 7 | 8 | const key = element.id; 9 | const position = { 10 | left: element.style.left, 11 | top: element.style.top, 12 | }; 13 | 14 | savedPositions[key] = position; 15 | localStorage.setItem(StorageKey, JSON.stringify(savedPositions)); 16 | } 17 | 18 | export function restorePosition(element: HTMLElement, shouldSave: boolean, options:any): void { 19 | if (!shouldSave) { 20 | element.style.left = options.initialPosition.x; 21 | element.style.top = options.initialPosition.y; 22 | } else { 23 | const savedPositions = JSON.parse(localStorage.getItem(StorageKey) || '{}'); 24 | 25 | const key = element.id; 26 | if (savedPositions[key]) { 27 | const position = savedPositions[key]; 28 | element.style.left = position.left || '0px'; 29 | element.style.top = position.top || '0px'; 30 | } else { 31 | element.style.left = options.initialPosition.x; 32 | element.style.top = options.initialPosition.y; 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: './src/drag/index.ts', 9 | name: 'DragKit', 10 | fileName: 'drag-kit', 11 | formats: ['es', 'umd'], 12 | }, 13 | rollupOptions: { 14 | output: { 15 | dir: 'lib', 16 | }, 17 | plugins: [ 18 | typescript({ 19 | tsconfig: './tsconfig.json', // 确保 tsconfig.json 路径正确 20 | exclude: ['node_modules', '**/__tests__/**'], // 确保正确排除不必要的文件 21 | }), 22 | ], 23 | }, 24 | minify: 'terser', 25 | terserOptions: { 26 | compress: { 27 | drop_console: true, // 删除 console 28 | pure_funcs: ['console.info', 'console.debug'], // 删除指定函数调用 29 | }, 30 | mangle: { 31 | properties: { 32 | regex: /^_/, // 混淆以 _ 开头的私有属性 33 | }, 34 | }, 35 | // format: { 36 | // comments: false, // 删除所有注释 37 | // }, 38 | toplevel: true, // 混淆顶级作用域 39 | keep_fnames: false, // 不保留函数名 40 | }, 41 | }, 42 | plugins: [vue()], 43 | resolve: { 44 | alias: { 45 | '@': '/src', 46 | } 47 | }, 48 | server: { 49 | port: 5173, // 确保端口号正确 50 | open: true, // 可选,是否自动打开浏览器 51 | proxy: { 52 | // 可选,配置代理 53 | } 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drag-kit", 3 | "version": "1.1.0", 4 | "description": "Lightweight cross-platform drag library supporting mobile, tablet, PC with Vue2/Vue3/React compatibility and full TypeScript support", 5 | "main": "lib/drag-kit.umd.js", 6 | "module": "lib/drag-kit.mjs", 7 | "types": "lib/types/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "scripts": { 12 | "build": "vite build", 13 | "dev": "vite --config vite.config.ts" 14 | }, 15 | "author": "Sailing", 16 | "license": "MIT", 17 | "homepage": "https://github.com/SailingCoder/drag-kit#readme", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/SailingCoder/drag-kit.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/SailingCoder/drag-kit/issues" 24 | }, 25 | "keywords": [ 26 | "drag", 27 | "draggable", 28 | "mobile", 29 | "tablet", 30 | "ipad", 31 | "pc", 32 | "touch", 33 | "vue", 34 | "vue3", 35 | "vue2", 36 | "react", 37 | "typescript", 38 | "cross-platform" 39 | ], 40 | "publishConfig": { 41 | "registry": "https://registry.npmjs.org" 42 | }, 43 | "dependencies": { 44 | "pinia": "^2.1.7", 45 | "vue": "^3.4.31" 46 | }, 47 | "devDependencies": { 48 | "@rollup/plugin-typescript": "^11.1.6", 49 | "@vitejs/plugin-vue": "^5.0.5", 50 | "@vue/compiler-sfc": "^3.4.31", 51 | "less": "^4.2.0", 52 | "typescript": "^5.5.3", 53 | "vite": "^5.3.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/drag/createDraggable.ts: -------------------------------------------------------------------------------- 1 | import { Draggable } from './draggable'; 2 | import { MobileDraggable } from './mobileDraggable'; 3 | import { DraggableOptions } from './types'; 4 | 5 | export function createDraggable(elementId: string, options: DraggableOptions = {}): Draggable | MobileDraggable | null { 6 | const element = document.getElementById(elementId) as HTMLElement; 7 | 8 | // 如果找不到对应的元素,输出错误信息并返回 null 9 | if (!element) { 10 | console.error(`Element with id ${elementId} not found.` ); 11 | return null; 12 | } 13 | 14 | // 因为涉及到初始定位,所以元素的 display 属性为 none 15 | if (getComputedStyle(element).display !== 'none') { 16 | console.error(`Element with id ${elementId} is visible. It's recommended to set display: none for proper initial positioning.`); 17 | } 18 | 19 | // mode只能为空或者为'screen'、'page'、'container'中的一个 20 | if (options.mode && !['screen', 'page', 'container'].includes(options.mode)) { 21 | console.error('Invalid mode option. Valid options are "screen", "page", or "container".'); 22 | return null; 23 | } 24 | 25 | // 检查拖拽模式和容器模式是否匹配 26 | if (options.mode === 'container' && !options.dragArea || options.dragArea && options.mode !== 'container') { 27 | console.error('Draggable container requires a dragArea option.'); 28 | return null; 29 | } 30 | 31 | // 拖拽功能在 iframe 内部被禁用 32 | if (window.self !== window.top) { 33 | console.warn('Draggable is disabled inside iframes.'); 34 | element.remove(); 35 | return null; 36 | } else { 37 | if (getComputedStyle(element).display === 'none') { 38 | element.style.display = 'block'; 39 | } 40 | } 41 | 42 | // 元素已经被初始化为可拖拽的 43 | if (element.dataset.draggableInitialized) { 44 | console.warn(`Element with id ${elementId} is already draggable.`); 45 | return null; 46 | } 47 | element.dataset.draggableInitialized = 'true'; 48 | 49 | // 检测设备类型,自动选择合适的拖拽实现 50 | if (MobileDraggable.isMobileDevice()) { 51 | console.log('Mobile device detected, using MobileDraggable'); 52 | return new MobileDraggable(element, options); 53 | } else { 54 | console.log('Desktop device detected, using Draggable'); 55 | return new Draggable(element, options); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/DraggableExample.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 88 | -------------------------------------------------------------------------------- /src/dragv1/Draggable.js: -------------------------------------------------------------------------------- 1 | export function createDraggable (elementId, options = {}) { 2 | const element = document.getElementById(elementId) 3 | 4 | if (!element) { 5 | console.error(`Element with id ${elementId} not found.`) 6 | return null 7 | } 8 | 9 | if (window.self !== window.top) { 10 | console.warn('Draggable is disabled inside iframes.') 11 | element.remove() // 从 DOM 中移除该元素 12 | return null 13 | } else { 14 | element.style.display = 'block' 15 | } 16 | 17 | if (element.dataset.draggableInitialized) { 18 | console.warn(`Element with id ${elementId} is already draggable.`) 19 | return null 20 | } 21 | element.dataset.draggableInitialized = 'true' 22 | 23 | // 设置初始位置 24 | const initialX = options.initialPosition?.x || '0px' 25 | const initialY = options.initialPosition?.y || '0px' 26 | element.style.left = initialX 27 | element.style.top = initialY 28 | 29 | const defaultOptions = { 30 | shouldSave: false, 31 | onDragStart: undefined, 32 | onDrag: undefined, 33 | onDragEnd: undefined, 34 | dragArea: undefined, 35 | lockAxis: undefined, 36 | edgeBuffer: 0, 37 | gridSize: undefined, 38 | mode: 'viewport', 39 | snapMode: 'none' 40 | } 41 | 42 | const config = { ...defaultOptions, ...options } 43 | 44 | const draggable = { 45 | element, 46 | shouldSave: config.shouldSave, 47 | minX: 0, 48 | minY: 0, 49 | maxX: window.innerWidth - element.offsetWidth, 50 | maxY: window.innerHeight - element.offsetHeight, 51 | diffX: 0, 52 | diffY: 0, 53 | mouseMoveHandler: null, 54 | mouseUpHandler: null, 55 | 56 | updateBounds () { 57 | if (config.dragArea) { 58 | config.dragArea.style.position = 'relative' 59 | this.element.style.position = 'absolute' 60 | const { width, height } = config.dragArea.getBoundingClientRect() 61 | this.maxX = width - this.element.offsetWidth 62 | this.maxY = height - this.element.offsetHeight 63 | } else if (config.mode === 'viewport') { 64 | this.element.style.position = 'fixed' 65 | this.maxX = window.innerWidth - this.element.offsetWidth 66 | this.maxY = window.innerHeight - this.element.offsetHeight 67 | } else if (config.mode === 'fixed') { 68 | this.element.style.position = 'absolute' 69 | this.maxX = window.innerWidth - this.element.offsetWidth 70 | this.maxY = window.innerHeight - this.element.offsetHeight 71 | } 72 | }, 73 | 74 | savePosition () { 75 | if (this.shouldSave) { 76 | const position = { 77 | left: this.element.style.left, 78 | top: this.element.style.top 79 | } 80 | localStorage.setItem( 81 | `SailingDraggable${this.element.id}`, 82 | JSON.stringify(position) 83 | ) 84 | } 85 | }, 86 | 87 | restorePosition () { 88 | if (this.shouldSave) { 89 | const savedPosition = localStorage.getItem( 90 | `SailingDraggable${this.element.id}` 91 | ) 92 | if (savedPosition) { 93 | const position = JSON.parse(savedPosition) 94 | this.element.style.left = position.left || '0px' 95 | this.element.style.top = position.top || '0px' 96 | } 97 | } 98 | }, 99 | 100 | onMouseDown (event) { 101 | event.preventDefault() 102 | event.stopPropagation() 103 | 104 | this.diffX = event.clientX - this.element.offsetLeft 105 | this.diffY = event.clientY - this.element.offsetTop 106 | 107 | const iframes = document.getElementsByTagName('iframe') 108 | for (const iframe of iframes) { 109 | iframe.style.pointerEvents = 'none' 110 | } 111 | 112 | if (typeof this.element.setCapture !== 'undefined') { 113 | this.element.setCapture() 114 | } 115 | 116 | this.mouseMoveHandler = this.onMouseMove.bind(this) 117 | this.mouseUpHandler = this.onMouseUp.bind(this) 118 | 119 | document.addEventListener('mousemove', this.mouseMoveHandler) 120 | document.addEventListener('mouseup', this.mouseUpHandler) 121 | 122 | if (config.onDragStart) config.onDragStart(this.element) 123 | }, 124 | 125 | onMouseMove (event) { 126 | event.preventDefault() 127 | 128 | let moveX = event.clientX - this.diffX 129 | let moveY = event.clientY - this.diffY 130 | 131 | if (config.lockAxis === 'x') { 132 | moveY = this.element.offsetTop 133 | } else if (config.lockAxis === 'y') { 134 | moveX = this.element.offsetLeft 135 | } 136 | 137 | if (config.mode === 'restricted' && config.dragArea) { 138 | this.updateBounds() 139 | } else if (config.mode === 'viewport') { 140 | this.maxX = window.innerWidth - this.element.offsetWidth 141 | this.maxY = window.innerHeight - this.element.offsetHeight 142 | } 143 | 144 | const edgeBuffer = config.edgeBuffer || 0 145 | 146 | if (moveX < this.minX - edgeBuffer) moveX = this.minX - edgeBuffer 147 | if (moveX > this.maxX + edgeBuffer) moveX = this.maxX + edgeBuffer 148 | 149 | if (moveY < this.minY - edgeBuffer) moveY = this.minY - edgeBuffer 150 | if (moveY > this.maxY + edgeBuffer) moveY = this.maxY + edgeBuffer 151 | 152 | if (config.gridSize) { 153 | const gridSize = config.gridSize || 1 154 | moveX = Math.round(moveX / gridSize) * gridSize 155 | moveY = Math.round(moveY / gridSize) * gridSize 156 | } 157 | 158 | this.element.style.left = `${moveX}px` 159 | this.element.style.top = `${moveY}px` 160 | 161 | if (config.onDrag) config.onDrag(this.element) 162 | }, 163 | 164 | onMouseUp () { 165 | if (config.snapMode && config.snapMode !== 'none') { 166 | const snapBuffer = config.edgeBuffer || 0 167 | const elementRect = this.element.getBoundingClientRect() 168 | const viewportWidth = window.innerWidth 169 | const viewportHeight = window.innerHeight 170 | 171 | const distanceToLeft = this.element.offsetLeft 172 | const distanceToRight = 173 | viewportWidth - (this.element.offsetLeft + elementRect.width) 174 | const distanceToTop = this.element.offsetTop 175 | const distanceToBottom = 176 | viewportHeight - (this.element.offsetTop + elementRect.height) 177 | 178 | if (config.snapMode === 'auto') { 179 | const minDistance = Math.min( 180 | distanceToLeft, 181 | distanceToRight, 182 | distanceToTop, 183 | distanceToBottom 184 | ) 185 | if (minDistance === distanceToLeft) { 186 | this.element.style.left = `${snapBuffer}px` 187 | } else if (minDistance === distanceToRight) { 188 | this.element.style.left = `${viewportWidth - 189 | elementRect.width - 190 | snapBuffer}px` 191 | } else if (minDistance === distanceToTop) { 192 | this.element.style.top = `${snapBuffer}px` 193 | } else if (minDistance === distanceToBottom) { 194 | this.element.style.top = `${viewportHeight - 195 | elementRect.height - 196 | snapBuffer}px` 197 | } 198 | } else if (config.snapMode === 'right') { 199 | this.element.style.left = `${viewportWidth - 200 | elementRect.width - 201 | snapBuffer}px` 202 | } else if (config.snapMode === 'left') { 203 | this.element.style.left = `${snapBuffer}px` 204 | } else if (config.snapMode === 'top') { 205 | this.element.style.top = `${snapBuffer}px` 206 | } else if (config.snapMode === 'bottom') { 207 | this.element.style.top = `${viewportHeight - 208 | elementRect.height - 209 | snapBuffer}px` 210 | } 211 | } 212 | 213 | const iframes = document.getElementsByTagName('iframe') 214 | for (const iframe of iframes) { 215 | iframe.style.pointerEvents = '' 216 | } 217 | 218 | this.savePosition() 219 | document.removeEventListener('mousemove', this.mouseMoveHandler) 220 | document.removeEventListener('mouseup', this.mouseUpHandler) 221 | if (typeof this.element.releaseCapture !== 'undefined') { 222 | this.element.releaseCapture() 223 | } 224 | 225 | if (config.onDragEnd) config.onDragEnd(this.element) 226 | }, 227 | 228 | init () { 229 | this.restorePosition() 230 | this.element.onmousedown = this.onMouseDown.bind(this) 231 | window.addEventListener('resize', this.updateBounds.bind(this)) 232 | this.updateBounds() 233 | } 234 | } 235 | 236 | draggable.init() 237 | return draggable 238 | } 239 | -------------------------------------------------------------------------------- /src/dragv1/Draggable.ts: -------------------------------------------------------------------------------- 1 | export interface DraggableOptions { 2 | initialPosition?: { x?: string; y?: string }; 3 | shouldSave?: boolean; 4 | onDragStart?: (element: HTMLElement) => void; 5 | onDrag?: (element: HTMLElement) => void; 6 | onDragEnd?: (element: HTMLElement) => void; 7 | dragArea?: HTMLElement; 8 | lockAxis?: 'x' | 'y'; 9 | edgeBuffer?: number; 10 | gridSize?: number; 11 | mode?: 'viewport' | 'fixed' | 'restricted'; 12 | snapMode?: 'none' | 'auto' | 'right' | 'left' | 'top' | 'bottom'; 13 | } 14 | 15 | interface Draggable { 16 | element: HTMLElement; 17 | shouldSave: boolean; 18 | minX: number; 19 | minY: number; 20 | maxX: number; 21 | maxY: number; 22 | diffX: number; 23 | diffY: number; 24 | mouseMoveHandler: ((event: MouseEvent) => void) | null; 25 | mouseUpHandler: (() => void) | null; 26 | 27 | updateBounds(): void; 28 | savePosition(): void; 29 | restorePosition(): void; 30 | onMouseDown(event: MouseEvent): void; 31 | onMouseMove(event: MouseEvent): void; 32 | onMouseUp(): void; 33 | init(): void; 34 | } 35 | 36 | export function createDraggable(elementId: string, options: DraggableOptions = {}): Draggable | null { 37 | const element = document.getElementById(elementId) as HTMLElement; 38 | 39 | if (!element) { 40 | console.error(`Element with id ${elementId} not found.`); 41 | return null; 42 | } 43 | 44 | if (window.self !== window.top) { 45 | console.warn('Draggable is disabled inside iframes.'); 46 | element.remove(); 47 | return null; 48 | } else { 49 | element.style.display = 'block'; 50 | } 51 | 52 | if (element.dataset.draggableInitialized) { 53 | console.warn(`Element with id ${elementId} is already draggable.`); 54 | return null; 55 | } 56 | element.dataset.draggableInitialized = 'true'; 57 | 58 | // 设置初始位置 59 | const initialX = options.initialPosition?.x || '0px'; 60 | const initialY = options.initialPosition?.y || '0px'; 61 | element.style.left = initialX; 62 | element.style.top = initialY; 63 | 64 | const defaultOptions: DraggableOptions = { 65 | shouldSave: false, 66 | onDragStart: undefined, 67 | onDrag: undefined, 68 | onDragEnd: undefined, 69 | dragArea: undefined, 70 | lockAxis: undefined, 71 | edgeBuffer: 0, 72 | gridSize: undefined, 73 | mode: 'viewport', 74 | snapMode: 'none' 75 | }; 76 | 77 | const config = { ...defaultOptions, ...options }; 78 | 79 | const draggable: Draggable = { 80 | element, 81 | shouldSave: config.shouldSave || false, 82 | minX: 0, 83 | minY: 0, 84 | maxX: window.innerWidth - element.offsetWidth, 85 | maxY: window.innerHeight - element.offsetHeight, 86 | diffX: 0, 87 | diffY: 0, 88 | mouseMoveHandler: null, 89 | mouseUpHandler: null, 90 | 91 | updateBounds() { 92 | if (config.dragArea) { 93 | config.dragArea.style.position = 'relative'; 94 | this.element.style.position = 'absolute'; 95 | const { width, height } = config.dragArea.getBoundingClientRect(); 96 | this.maxX = width - this.element.offsetWidth; 97 | this.maxY = height - this.element.offsetHeight; 98 | } else if (config.mode === 'viewport') { 99 | this.element.style.position = 'fixed'; 100 | this.maxX = window.innerWidth - this.element.offsetWidth; 101 | this.maxY = window.innerHeight - this.element.offsetHeight; 102 | } else if (config.mode === 'fixed') { 103 | this.element.style.position = 'absolute'; 104 | this.maxX = window.innerWidth - this.element.offsetWidth; 105 | this.maxY = window.innerHeight - this.element.offsetHeight; 106 | } 107 | }, 108 | 109 | savePosition() { 110 | if (this.shouldSave) { 111 | const position = { 112 | left: this.element.style.left, 113 | top: this.element.style.top 114 | }; 115 | localStorage.setItem( 116 | `SailingDraggable${this.element.id}`, 117 | JSON.stringify(position) 118 | ); 119 | } 120 | }, 121 | 122 | restorePosition() { 123 | if (this.shouldSave) { 124 | const savedPosition = localStorage.getItem( 125 | `SailingDraggable${this.element.id}` 126 | ); 127 | if (savedPosition) { 128 | const position = JSON.parse(savedPosition); 129 | this.element.style.left = position.left || '0px'; 130 | this.element.style.top = position.top || '0px'; 131 | } 132 | } 133 | }, 134 | 135 | onMouseDown(event: MouseEvent) { 136 | event.preventDefault(); 137 | event.stopPropagation(); 138 | 139 | this.diffX = event.clientX - this.element.offsetLeft; 140 | this.diffY = event.clientY - this.element.offsetTop; 141 | 142 | const iframes = document.getElementsByTagName('iframe'); 143 | for (const iframe of iframes) { 144 | iframe.style.pointerEvents = 'none'; 145 | } 146 | 147 | if (typeof this.element.setCapture !== 'undefined') { 148 | this.element.setCapture(); 149 | } 150 | 151 | this.mouseMoveHandler = this.onMouseMove.bind(this); 152 | this.mouseUpHandler = this.onMouseUp.bind(this); 153 | 154 | document.addEventListener('mousemove', this.mouseMoveHandler); 155 | document.addEventListener('mouseup', this.mouseUpHandler); 156 | 157 | if (config.onDragStart) config.onDragStart(this.element); 158 | }, 159 | 160 | onMouseMove(event: MouseEvent) { 161 | event.preventDefault(); 162 | 163 | let moveX = event.clientX - this.diffX; 164 | let moveY = event.clientY - this.diffY; 165 | 166 | if (config.lockAxis === 'x') { 167 | moveY = this.element.offsetTop; 168 | } else if (config.lockAxis === 'y') { 169 | moveX = this.element.offsetLeft; 170 | } 171 | 172 | if (config.mode === 'restricted' && config.dragArea) { 173 | this.updateBounds(); 174 | } else if (config.mode === 'viewport') { 175 | this.maxX = window.innerWidth - this.element.offsetWidth; 176 | this.maxY = window.innerHeight - this.element.offsetHeight; 177 | } 178 | 179 | const edgeBuffer = config.edgeBuffer || 0; 180 | 181 | if (moveX < this.minX - edgeBuffer) moveX = this.minX - edgeBuffer; 182 | if (moveX > this.maxX + edgeBuffer) moveX = this.maxX + edgeBuffer; 183 | 184 | if (moveY < this.minY - edgeBuffer) moveY = this.minY - edgeBuffer; 185 | if (moveY > this.maxY + edgeBuffer) moveY = this.maxY + edgeBuffer; 186 | 187 | if (config.gridSize) { 188 | const gridSize = config.gridSize || 1; 189 | moveX = Math.round(moveX / gridSize) * gridSize; 190 | moveY = Math.round(moveY / gridSize) * gridSize; 191 | } 192 | 193 | this.element.style.left = `${moveX}px`; 194 | this.element.style.top = `${moveY}px`; 195 | 196 | if (config.onDrag) config.onDrag(this.element); 197 | }, 198 | 199 | onMouseUp() { 200 | if (config.snapMode && config.snapMode !== 'none') { 201 | const snapBuffer = config.edgeBuffer || 0; 202 | const elementRect = this.element.getBoundingClientRect(); 203 | const viewportWidth = window.innerWidth; 204 | const viewportHeight = window.innerHeight; 205 | 206 | const distanceToLeft = this.element.offsetLeft; 207 | const distanceToRight = 208 | viewportWidth - (this.element.offsetLeft + elementRect.width); 209 | const distanceToTop = this.element.offsetTop; 210 | const distanceToBottom = 211 | viewportHeight - (this.element.offsetTop + elementRect.height); 212 | 213 | if (config.snapMode === 'auto') { 214 | const minDistance = Math.min( 215 | distanceToLeft, 216 | distanceToRight, 217 | distanceToTop, 218 | distanceToBottom 219 | ); 220 | if (minDistance === distanceToLeft) { 221 | this.element.style.left = `${snapBuffer}px`; 222 | } else if (minDistance === distanceToRight) { 223 | this.element.style.left = `${viewportWidth - 224 | elementRect.width - 225 | snapBuffer}px`; 226 | } else if (minDistance === distanceToTop) { 227 | this.element.style.top = `${snapBuffer}px`; 228 | } else if (minDistance === distanceToBottom) { 229 | this.element.style.top = `${viewportHeight - 230 | elementRect.height - 231 | snapBuffer}px`; 232 | } 233 | } else if (config.snapMode === 'right') { 234 | this.element.style.left = `${viewportWidth - 235 | elementRect.width - 236 | snapBuffer}px`; 237 | } else if (config.snapMode === 'left') { 238 | this.element.style.left = `${snapBuffer}px`; 239 | } else if (config.snapMode === 'top') { 240 | this.element.style.top = `${snapBuffer}px`; 241 | } else if (config.snapMode === 'bottom') { 242 | this.element.style.top = `${viewportHeight - 243 | elementRect.height - 244 | snapBuffer}px`; 245 | } 246 | } 247 | 248 | const iframes = document.getElementsByTagName('iframe'); 249 | for (const iframe of iframes) { 250 | iframe.style.pointerEvents = ''; 251 | } 252 | 253 | this.savePosition(); 254 | document.removeEventListener('mousemove', this.mouseMoveHandler as EventListener); 255 | document.removeEventListener('mouseup', this.mouseUpHandler as EventListener); 256 | if (typeof this.element.releaseCapture !== 'undefined') { 257 | this.element.releaseCapture(); 258 | } 259 | 260 | if (config.onDragEnd) config.onDragEnd(this.element); 261 | }, 262 | 263 | init() { 264 | this.restorePosition(); 265 | this.element.onmousedown = this.onMouseDown.bind(this); 266 | window.addEventListener('resize', this.updateBounds.bind(this)); 267 | this.updateBounds(); 268 | } 269 | }; 270 | 271 | draggable.init(); 272 | return draggable; 273 | } 274 | -------------------------------------------------------------------------------- /doc/README_EN.md: -------------------------------------------------------------------------------- 1 | # `drag-kit` - 轻量级可拖拽元素库 2 | 3 | `drag-kit` 是一个轻量级的 JavaScript 库,旨在实现元素的拖拽功能。drag-kit 提供了多种配置选项,包括初始位置、位置保存、拖拽区域限制、网格对齐和自动吸附等功能。该库不仅处理了内嵌 iframe 带来的拖拽问题,还兼容 Vue 2、Vue 3 和 React 等主流前端框架。 4 | 5 | ![npm version](https://img.shields.io/npm/v/drag-kit) 6 | 7 | [简体中文](https://github.com/SailingCoder/drag-kit/blob/main/doc/README_EN.md) | [English](https://github.com/SailingCoder/drag-kit/blob/main/README.md) 8 | 9 | ## 特性 10 | 11 | - **基础拖拽**:支持拖拽指定元素。 12 | - **全设备支持**:自动检测设备类型,支持手机、平板(iPad)、PC端,使用统一 API。 13 | - **方向锁定**:支持锁定拖拽方向(水平或垂直)。 14 | - **网格对齐**:支持拖拽时对齐到指定网格。 15 | - **自动吸附**:支持将元素自动吸附到视口边缘。 16 | - **边缘缓冲**:支持设置元素与边缘的缓冲距离。 17 | - **边界限制**:支持防止元素拖动超出指定区域。 18 | - **保存和恢复位置**:支持将拖拽位置保存到本地存储,并在页面加载时恢复。 19 | - **iframe 兼容**:处理 iframe 内的拖拽问题,确保兼容性。 20 | - **支持多种前端框架**:适用于 Vue 2、Vue 3、React 等前端框架。 21 | - **TypeScript 支持**:完整的类型定义,支持类型推断和智能提示。 22 | 23 | ![效果动态图](https://i-blog.csdnimg.cn/direct/22b05079dbe744439933dcbcf860a065.gif) 24 | 25 | ## 安装 26 | 27 | ```bash 28 | npm install drag-kit 29 | ``` 30 | 31 | ## 使用方法 32 | 33 | ### 快速开始 34 | 35 | 在 Vue 中,使用 onMounted 钩子: 36 | 37 | ```html 38 | 41 | 42 | 56 | ``` 57 | 58 | 在 React 中,使用 useEffect 钩子: 59 | 60 | ```tsx 61 | import React, { useEffect } from 'react'; 62 | import { createDraggable } from 'drag-kit'; 63 | 64 | const DraggableComponent: React.FC = () => { 65 | useEffect(() => { 66 | createDraggable('draggableElement', { 67 | initialPosition: { x: '100px', y: '200px' } 68 | }); 69 | }, []); 70 | 71 | return
Drag me!
; 72 | }; 73 | 74 | export default DraggableComponent; 75 | ``` 76 | 77 | 在纯 JavaScript 中使用: 78 | 79 | ```html 80 | 81 | 82 | 83 | 84 | 85 | Drag Kit Example 86 | 95 | 96 | 97 |
拖拽我!
98 | 99 | 100 | 108 | 109 | 110 | ``` 111 | 112 | **建议:将元素在初始化前设置为 display: none,提升更好的交互效果。** 113 | 114 | ### API 115 | 116 | ```ts 117 | createDraggable(elementId: string, options?: DraggableOptions): Draggable | MobileDraggable; 118 | ``` 119 | 120 | **自动设备检测**:`createDraggable` 会自动检测当前设备类型: 121 | - **移动设备**:返回 `MobileDraggable` 实例(触摸事件) 122 | - **桌面设备**:返回 `Draggable` 实例(鼠标事件) 123 | 124 | **参数** 125 | 126 | - **elementId**: 要使其可拖拽的元素的 ID。(必填) 127 | - **options**: 配置选项对象,支持以下字段:(选填) 128 | - `mode` (`'screen' | 'page' | 'container'`): 拖动模式(屏幕、页面或容器),默认是 `screen`。详见下文。 129 | - `initialPosition`: 元素的初始位置,默认 x = 0,y = 0。 130 | - `dragArea` (`HTMLElement`): 拖动区域(默认为 `null`,即全屏)。如果 `mode` 为 `container`,则需要设置该参数。 131 | - `lockAxis` (`'x' | 'y' | 'none'`): 锁定拖动轴(x 轴、y 轴或无)。 132 | - `edgeBuffer` (`number`): 边缘缓冲区。 133 | - `gridSize` (`number`): 拖动网格大小(默认为 `undefined`,即无网格对齐)。 134 | - `snapMode` (`'none' | 'auto' | 'right' | 'left' | 'top' | 'bottom'`): 自动吸附模式,默认为 `none`。 135 | - `shouldSave`: 是否将拖拽位置保存到本地存储。 136 | - `onDragStart`: 拖拽开始时的回调函数。 137 | - `onDrag`: 拖拽过程中的回调函数。 138 | - `onDragEnd`: 拖拽结束时的回调函数。 139 | 140 | 141 | 142 | **`mode` 参数详细说明** 143 | 144 | `mode` 参数定义了拖拽元素的拖动区域,决定了拖拽元素可以移动的范围: 145 | 146 | 1. **`screen` 模式** 147 | 元素只能在当前 **可视区域** 内拖拽,拖动范围受到屏幕边界限制。适用于需要固定在屏幕上的 UI 元素,如对话框、工具栏等。 148 | 149 | 2. **`page` 模式** 150 | 元素可以在整个 **页面范围** 内拖动,不受视口限制,即使页面有滚动条,元素也能被拖动到页面的任意位置,超出当前可视区域的部分可以通过滚动显示。 151 | 152 | 3. **`container` 模式** 153 | 元素只能在指定的 **容器** 内拖动,拖动区域受到容器边界的限制。通过设置 `dragArea` 参数来指定容器元素。适合局部拖动的场景,如面板或对话框内部的元素拖动。 154 | 155 | ## 跨平台支持 & TypeScript 156 | 157 | drag-kit 支持手机、平板(iPad)、PC端,提供完整的跨平台支持和 TypeScript 类型定义: 158 | 159 | ### 自动设备检测 160 | 系统会自动检测设备类型并选择合适的拖拽实现,无需额外配置: 161 | 162 | ```typescript 163 | import { createDraggable, DraggableOptions } from 'drag-kit'; 164 | 165 | // 完整的 TypeScript 类型支持 166 | const options: DraggableOptions = { 167 | mode: 'screen', 168 | initialPosition: { x: '100px', y: '200px' }, 169 | lockAxis: 'y', 170 | gridSize: 50, 171 | snapMode: 'auto', 172 | onDragStart: (element: HTMLElement) => { 173 | console.log('拖拽开始', element); 174 | }, 175 | onDrag: (element: HTMLElement) => { 176 | console.log('拖拽中', element); 177 | }, 178 | onDragEnd: (element: HTMLElement) => { 179 | console.log('拖拽结束', element); 180 | } 181 | }; 182 | 183 | // 自动类型推断:Draggable | MobileDraggable | null 184 | const draggable = createDraggable('elementId', options); 185 | ``` 186 | 187 | ### 触摸设备特性(手机/平板) 188 | - **触摸拖拽**:支持单指触摸拖拽 189 | - **防止滚动**:拖拽时自动防止页面滚动 190 | - **多点触摸处理**:只响应第一个触摸点 191 | - **完整兼容**:支持所有PC端功能(网格、吸附、轴锁定等) 192 | 193 | ### 手动控制 194 | ```typescript 195 | import { Draggable, MobileDraggable } from 'drag-kit'; 196 | 197 | // 强制使用PC端实现 198 | const desktopDraggable: Draggable = new Draggable(element, options); 199 | 200 | // 强制使用触摸设备实现 201 | const mobileDraggable: MobileDraggable = new MobileDraggable(element, options); 202 | 203 | // 检测是否为触摸设备(手机/平板) 204 | const isMobile: boolean = MobileDraggable.isMobileDevice(); 205 | ``` 206 | 207 | ## 性能优化 208 | 209 | 为了避免性能开销,建议在不需要拖拽功能时销毁实例,特别是在元素被移除或视图销毁时。 210 | 211 | **在 Vue 中销毁实例** 212 | 213 | ```html 214 | 217 | 218 | 238 | ``` 239 | 240 | **在 React 中销毁实例** 241 | 242 | ```tsx 243 | import React, { useEffect } from 'react'; 244 | import { createDraggable } from 'drag-kit'; 245 | 246 | const DraggableComponent: React.FC = () => { 247 | useEffect(() => { 248 | const draggable = createDraggable('draggableElement', { 249 | initialPosition: { x: '100px', y: '200px' } 250 | }); 251 | 252 | return () => { 253 | draggable?.destroy(); 254 | }; 255 | }, []); 256 | 257 | return
Drag me!
; 258 | }; 259 | 260 | export default DraggableComponent; 261 | ``` 262 | 263 | ## 示例集合(以 Vue3 举例) 264 | 265 | ![效果动态图](https://i-blog.csdnimg.cn/direct/22b05079dbe744439933dcbcf860a065.gif) 266 | 267 | 268 | ```html 269 | 306 | 307 | 356 | 382 | ``` 383 | 384 | ## 结语 385 | 386 | `drag-kit` 是一个简洁高效的跨平台拖拽解决方案,适用于桌面端和移动端的多种拖拽场景。若有任何建议或问题,欢迎在[GitHub Issue](https://github.com/SailingCoder/drag-kit/issues) 中反馈。 387 | -------------------------------------------------------------------------------- /src/drag/draggable.ts: -------------------------------------------------------------------------------- 1 | import { DraggableOptions } from './types'; 2 | import { savePosition, restorePosition } from './utils'; 3 | 4 | export class Draggable { 5 | element: HTMLElement; 6 | shouldSave: boolean; 7 | minX: number; 8 | minY: number; 9 | maxX: number; 10 | maxY: number; 11 | diffX: number; 12 | diffY: number; 13 | mouseMoveHandler: ((event: MouseEvent) => void) | null; 14 | mouseUpHandler: ((event: MouseEvent) => void) | null; 15 | observerElement: MutationObserver | null; 16 | config: DraggableOptions; 17 | initialPosition: { x: string; y: string }; 18 | startX: number; 19 | startY: number; 20 | dragThreshold: number; 21 | 22 | constructor(element: HTMLElement, options: DraggableOptions = {}) { 23 | this.element = element; 24 | this.shouldSave = options.shouldSave || false; 25 | this.minX = 0; 26 | this.minY = 0; 27 | this.maxX = window.innerWidth - element.offsetWidth; 28 | this.maxY = window.innerHeight - element.offsetHeight; 29 | this.diffX = 0; 30 | this.diffY = 0; 31 | this.mouseMoveHandler = null; 32 | this.mouseUpHandler = null; 33 | this.observerElement = null; 34 | this.config = { ...Draggable.defaultOptions, ...options }; 35 | this.initialPosition = { 36 | x: options.initialPosition?.x || '0px', 37 | y: options.initialPosition?.y || '0px', 38 | }; 39 | this.startX = 0; 40 | this.startY = 0; 41 | this.dragThreshold = 5; // 判定为拖动的最小移动距离 42 | 43 | this.init(); 44 | } 45 | 46 | static defaultOptions: DraggableOptions = { 47 | shouldSave: false, 48 | onDragStart: undefined, 49 | onDrag: undefined, 50 | onDragEnd: undefined, 51 | dragArea: undefined, 52 | lockAxis: undefined, 53 | edgeBuffer: 0, 54 | gridSize: undefined, 55 | mode: 'screen', 56 | snapMode: 'none', 57 | }; 58 | 59 | updateBounds(): void { 60 | if (this.config.dragArea) { 61 | // 判断dragArea中position是否有值,如果没有则设置为relative 62 | if (getComputedStyle(this.config.dragArea).position === 'static') { 63 | this.config.dragArea.style.position = 'relative'; 64 | } 65 | this.element.style.position = 'absolute'; 66 | const { width, height } = this.config.dragArea.getBoundingClientRect(); 67 | this.maxX = width - this.element.offsetWidth; 68 | this.maxY = height - this.element.offsetHeight; 69 | } else if (this.config.mode === 'screen') { 70 | this.element.style.position = 'fixed'; 71 | this.maxX = window.innerWidth - this.element.offsetWidth; 72 | this.maxY = window.innerHeight - this.element.offsetHeight; 73 | } else if (this.config.mode === 'page') { 74 | this.element.style.position = 'absolute'; 75 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 76 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 77 | } 78 | } 79 | 80 | onMouseDown(event: MouseEvent): void { 81 | // 检查是否是右键点击,如果是右键,直接返回 82 | if (event.button === 2) { 83 | return; 84 | } 85 | 86 | event.preventDefault(); 87 | event.stopPropagation(); 88 | 89 | this.startX = event.clientX; 90 | this.startY = event.clientY; 91 | 92 | this.diffX = event.clientX - this.element.offsetLeft; 93 | this.diffY = event.clientY - this.element.offsetTop; 94 | 95 | const iframes = document.getElementsByTagName('iframe'); 96 | for (const iframe of iframes) { 97 | iframe.style.pointerEvents = 'none'; 98 | } 99 | 100 | if (typeof this.element.setCapture !== 'undefined') { 101 | this.element.setCapture(); 102 | } 103 | 104 | this.mouseMoveHandler = this.onMouseMove.bind(this); 105 | this.mouseUpHandler = this.onMouseUp.bind(this); 106 | 107 | document.addEventListener('mousemove', this.mouseMoveHandler); 108 | document.addEventListener('mouseup', this.mouseUpHandler); 109 | 110 | if (this.config.onDragStart) this.config.onDragStart(this.element); 111 | } 112 | 113 | onMouseMove(event: MouseEvent): void { 114 | event.preventDefault(); 115 | 116 | let moveX = event.clientX - this.diffX; 117 | let moveY = event.clientY - this.diffY; 118 | 119 | if (this.config.lockAxis === 'x') { 120 | moveY = this.element.offsetTop; 121 | } else if (this.config.lockAxis === 'y') { 122 | moveX = this.element.offsetLeft; 123 | } 124 | 125 | if (this.config.mode === 'container' && this.config.dragArea) { 126 | this.updateBounds(); 127 | } else if (this.config.mode === 'screen') { 128 | this.maxX = window.innerWidth - this.element.offsetWidth; 129 | this.maxY = window.innerHeight - this.element.offsetHeight; 130 | } else if (this.config.mode === 'page') { 131 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 132 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 133 | } 134 | 135 | const edgeBuffer = this.config.edgeBuffer || 0; 136 | 137 | if (moveX < this.minX - edgeBuffer) moveX = this.minX - edgeBuffer; 138 | if (moveX > this.maxX + edgeBuffer) moveX = this.maxX + edgeBuffer; 139 | 140 | if (moveY < this.minY - edgeBuffer) moveY = this.minY - edgeBuffer; 141 | if (moveY > this.maxY + edgeBuffer) moveY = this.maxY + edgeBuffer; 142 | 143 | if (this.config.gridSize) { 144 | const gridSize = this.config.gridSize || 1; 145 | moveX = Math.round(moveX / gridSize) * gridSize; 146 | moveY = Math.round(moveY / gridSize) * gridSize; 147 | } 148 | 149 | this.element.style.left = `${moveX}px`; 150 | this.element.style.top = `${moveY}px`; 151 | 152 | if (this.config.onDrag) this.config.onDrag(this.element); 153 | } 154 | 155 | stopPreventEvent(event: MouseEvent): void { 156 | event.stopPropagation(); 157 | event.preventDefault(); 158 | } 159 | 160 | disableClickEvent(): void { 161 | this.element.addEventListener( 162 | 'click', 163 | this.stopPreventEvent, 164 | true 165 | ); 166 | 167 | setTimeout(() => { 168 | this.element.removeEventListener('click', this.stopPreventEvent, true); 169 | }, 0); 170 | } 171 | 172 | onMouseUp(event:any): void { 173 | const moveX = Math.abs(event.clientX - this.startX); 174 | const moveY = Math.abs(event.clientY - this.startY); 175 | 176 | if (moveX > this.dragThreshold || moveY > this.dragThreshold) { 177 | this.disableClickEvent() 178 | } 179 | 180 | if (this.config.snapMode && this.config.snapMode !== 'none') { 181 | const snapBuffer = this.config.edgeBuffer || 0; 182 | const elementRect = this.element.getBoundingClientRect(); 183 | 184 | let viewportWidth, viewportHeight; 185 | // 当 mode 为 screen 时,吸附窗口的边缘; 186 | // 当 mode 为 page 时,吸附页面内容的边缘(即可以拖动并吸附到页面超出可视范围的部分); 187 | // 当 mode 为 container 时,吸附到容器边缘。 188 | if (this.config.mode === 'screen') { 189 | viewportWidth = window.innerWidth; 190 | viewportHeight = window.innerHeight; 191 | } else if (this.config.mode === 'page') { 192 | viewportWidth = document.documentElement.scrollWidth; 193 | viewportHeight = document.documentElement.scrollHeight; 194 | } else if (this.config.mode === 'container' && this.config.dragArea) { 195 | const dragAreaRect = this.config.dragArea.getBoundingClientRect(); 196 | viewportWidth = dragAreaRect.width; 197 | viewportHeight = dragAreaRect.height; 198 | } else { // 默认为 screen 199 | viewportWidth = window.innerWidth; 200 | viewportHeight = window.innerHeight; 201 | } 202 | 203 | const distanceToLeft = this.element.offsetLeft; 204 | const distanceToRight = 205 | viewportWidth - (this.element.offsetLeft + elementRect.width); 206 | const distanceToTop = this.element.offsetTop; 207 | const distanceToBottom = 208 | viewportHeight - (this.element.offsetTop + elementRect.height); 209 | 210 | if (this.config.snapMode === 'auto') { 211 | const minDistance = Math.min( 212 | distanceToLeft, 213 | distanceToRight, 214 | distanceToTop, 215 | distanceToBottom 216 | ); 217 | if (minDistance === distanceToLeft) { 218 | this.element.style.left = `${snapBuffer}px`; 219 | } else if (minDistance === distanceToRight) { 220 | this.element.style.left = `${viewportWidth - 221 | elementRect.width - 222 | snapBuffer}px`; 223 | } else if (minDistance === distanceToTop) { 224 | this.element.style.top = `${snapBuffer}px`; 225 | } else if (minDistance === distanceToBottom) { 226 | this.element.style.top = `${viewportHeight - 227 | elementRect.height - 228 | snapBuffer}px`; 229 | } 230 | } else if (this.config.snapMode === 'right') { 231 | this.element.style.left = `${viewportWidth - 232 | elementRect.width - 233 | snapBuffer}px`; 234 | } else if (this.config.snapMode === 'left') { 235 | this.element.style.left = `${snapBuffer}px`; 236 | } else if (this.config.snapMode === 'top') { 237 | this.element.style.top = `${snapBuffer}px`; 238 | } else if (this.config.snapMode === 'bottom') { 239 | this.element.style.top = `${viewportHeight - 240 | elementRect.height - 241 | snapBuffer}px`; 242 | } 243 | } 244 | 245 | const iframes = document.getElementsByTagName('iframe'); 246 | for (const iframe of iframes) { 247 | iframe.style.pointerEvents = ''; 248 | } 249 | 250 | this.savePosition(); 251 | document.removeEventListener('mousemove', this.mouseMoveHandler as EventListener); 252 | document.removeEventListener('mouseup', this.mouseUpHandler as EventListener); 253 | if (typeof this.element.releaseCapture !== 'undefined') { 254 | this.element.releaseCapture(); 255 | } 256 | 257 | if (this.config.onDragEnd) this.config.onDragEnd(this.element); 258 | } 259 | 260 | savePosition(): void { 261 | if (!this.shouldSave) return; 262 | savePosition(this.element, this.shouldSave); 263 | } 264 | 265 | restorePosition(): void { 266 | restorePosition(this.element, this.shouldSave, { initialPosition: this.initialPosition }); 267 | } 268 | 269 | // 解决vue、React等框架中,重复渲染导致的元素 display 属性被修改的问题 270 | observeElementVisibility(element: HTMLElement) { 271 | this.observerElement = new MutationObserver((mutations) => { 272 | mutations.forEach((mutation) => { 273 | if ( 274 | mutation.attributeName === "style" && 275 | element.style.display === "none" 276 | ) { 277 | // 恢复为可见 278 | element.style.display = "block"; 279 | } 280 | }); 281 | }); 282 | 283 | this.observerElement.observe(element, { 284 | attributes: true, 285 | attributeFilter: ["style"], 286 | }); 287 | } 288 | 289 | init(): void { 290 | // 设置初始位置 291 | this.restorePosition(); 292 | this.element.onmousedown = this.onMouseDown.bind(this); 293 | window.addEventListener('resize', this.updateBounds.bind(this)); 294 | this.updateBounds(); 295 | this.observeElementVisibility(this.element); 296 | } 297 | 298 | destroy(): void { 299 | try { 300 | // 移除元素上的 mousedown 事件监听器 301 | this.element.onmousedown = null; 302 | 303 | // 如果鼠标移动和鼠标松开事件仍在监听,移除这些监听器 304 | if (this.mouseMoveHandler) { 305 | document.removeEventListener('mousemove', this.mouseMoveHandler); 306 | this.mouseMoveHandler = null; 307 | } 308 | 309 | if (this.mouseUpHandler) { 310 | document.removeEventListener('mouseup', this.mouseUpHandler); 311 | this.mouseUpHandler = null; 312 | } 313 | 314 | // 移除窗口的 resize 事件监听器 315 | window.removeEventListener('resize', this.updateBounds); 316 | 317 | // 如果元素的 display 属性被修改,停止监听这些变化 318 | if (this.observerElement) { 319 | this.observerElement.disconnect(); 320 | this.observerElement = null; 321 | } 322 | } catch (error) { 323 | console.warn('Error in destroy method:', error); 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `drag-kit` - A Lightweight Draggable Element Library 2 | 3 | `drag-kit` is a lightweight JavaScript library designed to implement drag-and-drop functionality for elements. It offers various configuration options, including initial positioning, position saving, drag area constraints, grid alignment, and auto-snapping. The library handles drag-and-drop issues within iframes and is compatible with major front-end frameworks such as Vue 2, Vue 3, and React. 4 | 5 | ![npm version](https://img.shields.io/npm/v/drag-kit) 6 | 7 | [简体中文](https://github.com/SailingCoder/drag-kit/blob/main/doc/README_EN.md) | [English](https://github.com/SailingCoder/drag-kit/blob/main/README.md) 8 | 9 | ## Features 10 | 11 | - **Basic Dragging**: Drag specified elements. 12 | - **Cross-Device Support**: Auto-detect device type, supports mobile phones, tablets (iPad), and PC with unified API. 13 | - **Axis Locking**: Lock dragging to a specific direction (horizontal or vertical). 14 | - **Grid Alignment**: Align dragging to a specified grid. 15 | - **Auto-Snapping**: Automatically snap elements to viewport edges. 16 | - **Edge Buffering**: Set a buffer distance between the element and the edges. 17 | - **Boundary Limiting**: Prevent elements from being dragged outside a specified area. 18 | - **Position Saving and Restoring**: Save drag positions to local storage and restore them on page reload. 19 | - **Iframe Compatibility**: Handle drag issues within iframes to ensure compatibility. 20 | - **Framework Support**: Works with Vue 2, Vue 3, React, and other major front-end frameworks. 21 | - **TypeScript Support**: Complete type definitions with type inference and IntelliSense. 22 | 23 | ![img gif](https://i-blog.csdnimg.cn/direct/22b05079dbe744439933dcbcf860a065.gif) 24 | 25 | ## Installation 26 | 27 | ```bash 28 | npm install drag-kit 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### Quick Start 34 | 35 | In Vue, using the onMounted hook: 36 | 37 | ```html 38 | 41 | 42 | 56 | ``` 57 | 58 | In React, using the useEffect hook: 59 | 60 | ```tsx 61 | import React, { useEffect } from 'react'; 62 | import { createDraggable } from 'drag-kit'; 63 | 64 | const DraggableComponent: React.FC = () => { 65 | useEffect(() => { 66 | createDraggable('draggableElement', { 67 | initialPosition: { x: '100px', y: '200px' } 68 | }); 69 | }, []); 70 | 71 | return
Drag me!
; 72 | }; 73 | 74 | export default DraggableComponent; 75 | ``` 76 | 77 | It's recommended to set the element's display to none before initialization to enhance the user experience. 78 | 79 | ### Parameter Details 80 | 81 | ```ts 82 | createDraggable(elementId: string, options?: DraggableOptions): Draggable | MobileDraggable; 83 | ``` 84 | 85 | **Auto Device Detection**: `createDraggable` automatically detects the current device type: 86 | - **Touch Devices**: Returns `MobileDraggable` instance (touch events) 87 | - **Desktop Devices**: Returns `Draggable` instance (mouse events) 88 | 89 | **Parameters** 90 | 91 | - **elementId**: The ID of the element to make draggable. (Required) 92 | - **options**: Configuration object with the following optional fields: 93 | - `mode` (`'screen' | 'page' | 'container'`): Drag mode (screen, page, or container). Default is `screen`. See details below. 94 | - `initialPosition`: Initial position of the element, default x = 0, y = 0. 95 | - `dragArea` (`HTMLElement`): Drag area (default is `null`, meaning full screen). Required if `mode` is `container`. 96 | - `lockAxis` (`'x' | 'y' | 'none'`): Lock dragging to a specific axis (x-axis, y-axis, or none). 97 | - `edgeBuffer` (`number`): Edge buffer distance. 98 | - `gridSize` (`number`): Grid size for alignment (default is `undefined`, meaning no grid alignment). 99 | - `snapMode` (`'none' | 'auto' | 'right' | 'left' | 'top' | 'bottom'`): Auto-snapping mode, default is `none`. 100 | - `shouldSave`: Whether to save the drag position to local storage. 101 | - `onDragStart`: Callback function when dragging starts. 102 | - `onDrag`: Callback function during dragging. 103 | - `onDragEnd`: Callback function when dragging ends. 104 | 105 | **Detailed Explanation of `mode` Parameter** 106 | 107 | The `mode` parameter defines the drag area and determines where the element can be moved: 108 | 109 | 1. **`screen` Mode** 110 | The element can only be dragged within the current **viewport**, restricted to the screen boundaries. This mode is suitable for UI elements that need to remain within the screen, such as dialogs or toolbars. 111 | 112 | 2. **`page` Mode** 113 | The element can be dragged anywhere within the **page** boundaries, regardless of viewport limits. The element can be moved to any part of the page, and overflow can be scrolled to view. 114 | 115 | 3. **`container` Mode** 116 | The element can only be dragged within a specified **container**. The drag area is constrained by the container's boundaries. Set the `dragArea` parameter to specify the container element. This mode is suitable for dragging within specific areas like panels or dialogs. 117 | 118 | ## Cross-Platform Support & TypeScript 119 | 120 | drag-kit supports mobile phones, tablets (iPad), and PC with complete cross-platform support and TypeScript type definitions: 121 | 122 | ### Auto Device Detection 123 | The system automatically detects device type and selects appropriate drag implementation without additional configuration: 124 | 125 | ```typescript 126 | import { createDraggable, DraggableOptions } from 'drag-kit'; 127 | 128 | // Complete TypeScript type support 129 | const options: DraggableOptions = { 130 | mode: 'screen', 131 | initialPosition: { x: '100px', y: '200px' }, 132 | lockAxis: 'y', 133 | gridSize: 50, 134 | snapMode: 'auto', 135 | onDragStart: (element: HTMLElement) => { 136 | console.log('Drag started', element); 137 | }, 138 | onDrag: (element: HTMLElement) => { 139 | console.log('Dragging', element); 140 | }, 141 | onDragEnd: (element: HTMLElement) => { 142 | console.log('Drag ended', element); 143 | } 144 | }; 145 | 146 | // Auto type inference: Draggable | MobileDraggable | null 147 | const draggable = createDraggable('elementId', options); 148 | ``` 149 | 150 | ### Touch Device Features (Mobile/Tablet) 151 | - **Touch Dragging**: Support single-finger touch dragging 152 | - **Prevent Scrolling**: Automatically prevent page scrolling during dragging 153 | - **Multi-touch Handling**: Only respond to the first touch point 154 | - **Full Compatibility**: Support all PC features (grid, snapping, axis locking, etc.) 155 | 156 | ### Manual Control 157 | ```typescript 158 | import { Draggable, MobileDraggable } from 'drag-kit'; 159 | 160 | // Force PC implementation 161 | const desktopDraggable: Draggable = new Draggable(element, options); 162 | 163 | // Force touch device implementation 164 | const mobileDraggable: MobileDraggable = new MobileDraggable(element, options); 165 | 166 | // Detect if it's a touch device (mobile/tablet) 167 | const isMobile: boolean = MobileDraggable.isMobileDevice(); 168 | ``` 169 | 170 | ## Performance Optimization 171 | 172 | To avoid performance overhead, it's recommended to destroy the draggable instance when the element is removed or the view is destroyed, especially when dragging is no longer needed. 173 | 174 | **Destroying the instance in Vue** 175 | 176 | ```html 177 | 180 | 181 | 201 | ``` 202 | 203 | **Destroying the instance in React** 204 | 205 | ```tsx 206 | import React, { useEffect } from 'react'; 207 | import { createDraggable } from 'drag-kit'; 208 | 209 | const DraggableComponent: React.FC = () => { 210 | useEffect(() => { 211 | const draggable = createDraggable('draggableElement', { 212 | initialPosition: { x: '100px', y: '200px' } 213 | }); 214 | 215 | return () => { 216 | draggable?.destroy(); 217 | }; 218 | }, []); 219 | 220 | return
Drag me!
; 221 | }; 222 | 223 | export default DraggableComponent; 224 | ``` 225 | 226 | ## Example Collection (Vue 3) 227 | 228 | ![效果动态图](https://i-blog.csdnimg.cn/direct/22b05079dbe744439933dcbcf860a065.gif) 229 | 230 | 代码 231 | 232 | ```html 233 | 270 | 271 | 320 | 346 | ``` 347 | 348 | ## Conclusion 349 | 350 | `drag-kit` is a streamlined and efficient cross-platform drag-and-drop solution for mobile phones, tablets, and PC. If you have any suggestions or issues, please feel free to provide feedback on our [GitHub Issues page](https://github.com/SailingCoder/drag-kit/issues). -------------------------------------------------------------------------------- /src/drag/mobileDraggable.ts: -------------------------------------------------------------------------------- 1 | import { DraggableOptions } from './types'; 2 | import { savePosition, restorePosition } from './utils'; 3 | 4 | export class MobileDraggable { 5 | element: HTMLElement; 6 | shouldSave: boolean; 7 | minX: number; 8 | minY: number; 9 | maxX: number; 10 | maxY: number; 11 | diffX: number; 12 | diffY: number; 13 | touchMoveHandler: ((event: TouchEvent) => void) | null; 14 | touchEndHandler: ((event: TouchEvent) => void) | null; 15 | observerElement: MutationObserver | null; 16 | config: DraggableOptions; 17 | initialPosition: { x: string; y: string }; 18 | startX: number; 19 | startY: number; 20 | dragThreshold: number; 21 | isDragging: boolean; 22 | 23 | constructor(element: HTMLElement, options: DraggableOptions = {}) { 24 | this.element = element; 25 | this.shouldSave = options.shouldSave || false; 26 | this.minX = 0; 27 | this.minY = 0; 28 | this.maxX = window.innerWidth - element.offsetWidth; 29 | this.maxY = window.innerHeight - element.offsetHeight; 30 | this.diffX = 0; 31 | this.diffY = 0; 32 | this.touchMoveHandler = null; 33 | this.touchEndHandler = null; 34 | this.observerElement = null; 35 | this.config = { ...MobileDraggable.defaultOptions, ...options }; 36 | this.initialPosition = { 37 | x: options.initialPosition?.x || '0px', 38 | y: options.initialPosition?.y || '0px', 39 | }; 40 | this.startX = 0; 41 | this.startY = 0; 42 | this.dragThreshold = 5; // 判定为拖动的最小移动距离 43 | this.isDragging = false; 44 | 45 | this.init(); 46 | } 47 | 48 | static defaultOptions: DraggableOptions = { 49 | shouldSave: false, 50 | onDragStart: undefined, 51 | onDrag: undefined, 52 | onDragEnd: undefined, 53 | dragArea: undefined, 54 | lockAxis: undefined, 55 | edgeBuffer: 0, 56 | gridSize: undefined, 57 | mode: 'screen', 58 | snapMode: 'none', 59 | }; 60 | 61 | // 统一获取坐标的方法 62 | private getEventCoordinates(event: TouchEvent): { x: number; y: number } { 63 | if (event.touches && event.touches.length > 0) { 64 | return { x: event.touches[0].clientX, y: event.touches[0].clientY }; 65 | } 66 | // 如果没有 touches,尝试使用 changedTouches(在 touchend 事件中) 67 | if (event.changedTouches && event.changedTouches.length > 0) { 68 | return { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }; 69 | } 70 | return { x: 0, y: 0 }; 71 | } 72 | 73 | updateBounds(): void { 74 | if (this.config.dragArea) { 75 | // 判断dragArea中position是否有值,如果没有则设置为relative 76 | if (getComputedStyle(this.config.dragArea).position === 'static') { 77 | this.config.dragArea.style.position = 'relative'; 78 | } 79 | this.element.style.position = 'absolute'; 80 | const { width, height } = this.config.dragArea.getBoundingClientRect(); 81 | this.maxX = width - this.element.offsetWidth; 82 | this.maxY = height - this.element.offsetHeight; 83 | } else if (this.config.mode === 'screen') { 84 | this.element.style.position = 'fixed'; 85 | this.maxX = window.innerWidth - this.element.offsetWidth; 86 | this.maxY = window.innerHeight - this.element.offsetHeight; 87 | } else if (this.config.mode === 'page') { 88 | this.element.style.position = 'absolute'; 89 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 90 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 91 | } 92 | } 93 | 94 | onTouchStart(event: TouchEvent): void { 95 | // 只处理单点触摸 96 | if (event.touches.length !== 1) { 97 | return; 98 | } 99 | 100 | // 防止页面滚动和其他默认行为 101 | event.preventDefault(); 102 | event.stopPropagation(); 103 | 104 | const coords = this.getEventCoordinates(event); 105 | this.startX = coords.x; 106 | this.startY = coords.y; 107 | 108 | this.diffX = coords.x - this.element.offsetLeft; 109 | this.diffY = coords.y - this.element.offsetTop; 110 | 111 | this.isDragging = false; 112 | 113 | // 禁用 iframe 的指针事件,防止拖拽时出现问题 114 | const iframes = document.getElementsByTagName('iframe'); 115 | for (const iframe of iframes) { 116 | iframe.style.pointerEvents = 'none'; 117 | } 118 | 119 | this.touchMoveHandler = this.onTouchMove.bind(this); 120 | this.touchEndHandler = this.onTouchEnd.bind(this); 121 | 122 | document.addEventListener('touchmove', this.touchMoveHandler, { passive: false }); 123 | document.addEventListener('touchend', this.touchEndHandler, { passive: false }); 124 | document.addEventListener('touchcancel', this.touchEndHandler, { passive: false }); 125 | 126 | if (this.config.onDragStart) this.config.onDragStart(this.element); 127 | } 128 | 129 | onTouchMove(event: TouchEvent): void { 130 | // 只处理单点触摸 131 | if (event.touches.length !== 1) { 132 | return; 133 | } 134 | 135 | event.preventDefault(); 136 | 137 | const coords = this.getEventCoordinates(event); 138 | 139 | // 检查是否超过拖拽阈值 140 | const moveX = Math.abs(coords.x - this.startX); 141 | const moveY = Math.abs(coords.y - this.startY); 142 | 143 | if (!this.isDragging && (moveX > this.dragThreshold || moveY > this.dragThreshold)) { 144 | this.isDragging = true; 145 | } 146 | 147 | if (!this.isDragging) { 148 | return; 149 | } 150 | 151 | let newX = coords.x - this.diffX; 152 | let newY = coords.y - this.diffY; 153 | 154 | // 轴锁定 155 | if (this.config.lockAxis === 'x') { 156 | newY = this.element.offsetTop; 157 | } else if (this.config.lockAxis === 'y') { 158 | newX = this.element.offsetLeft; 159 | } 160 | 161 | // 更新边界 162 | if (this.config.mode === 'container' && this.config.dragArea) { 163 | this.updateBounds(); 164 | } else if (this.config.mode === 'screen') { 165 | this.maxX = window.innerWidth - this.element.offsetWidth; 166 | this.maxY = window.innerHeight - this.element.offsetHeight; 167 | } else if (this.config.mode === 'page') { 168 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 169 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 170 | } 171 | 172 | const edgeBuffer = this.config.edgeBuffer || 0; 173 | 174 | // 边界限制 175 | if (newX < this.minX - edgeBuffer) newX = this.minX - edgeBuffer; 176 | if (newX > this.maxX + edgeBuffer) newX = this.maxX + edgeBuffer; 177 | 178 | if (newY < this.minY - edgeBuffer) newY = this.minY - edgeBuffer; 179 | if (newY > this.maxY + edgeBuffer) newY = this.maxY + edgeBuffer; 180 | 181 | // 网格对齐 182 | if (this.config.gridSize) { 183 | const gridSize = this.config.gridSize || 1; 184 | newX = Math.round(newX / gridSize) * gridSize; 185 | newY = Math.round(newY / gridSize) * gridSize; 186 | } 187 | 188 | this.element.style.left = `${newX}px`; 189 | this.element.style.top = `${newY}px`; 190 | 191 | if (this.config.onDrag) this.config.onDrag(this.element); 192 | } 193 | 194 | stopPreventEvent(event: TouchEvent): void { 195 | event.stopPropagation(); 196 | event.preventDefault(); 197 | } 198 | 199 | disableClickEvent(): void { 200 | this.element.addEventListener( 201 | 'click', 202 | this.stopPreventEvent as any, 203 | true 204 | ); 205 | 206 | setTimeout(() => { 207 | this.element.removeEventListener('click', this.stopPreventEvent as any, true); 208 | }, 0); 209 | } 210 | 211 | onTouchEnd(event: TouchEvent): void { 212 | const coords = this.getEventCoordinates(event); 213 | const moveX = Math.abs(coords.x - this.startX); 214 | const moveY = Math.abs(coords.y - this.startY); 215 | 216 | if (this.isDragging || moveX > this.dragThreshold || moveY > this.dragThreshold) { 217 | this.disableClickEvent(); 218 | } 219 | 220 | // 自动吸附逻辑 221 | if (this.config.snapMode && this.config.snapMode !== 'none' && this.isDragging) { 222 | const snapBuffer = this.config.edgeBuffer || 0; 223 | const elementRect = this.element.getBoundingClientRect(); 224 | 225 | let viewportWidth, viewportHeight; 226 | 227 | if (this.config.mode === 'screen') { 228 | viewportWidth = window.innerWidth; 229 | viewportHeight = window.innerHeight; 230 | } else if (this.config.mode === 'page') { 231 | viewportWidth = document.documentElement.scrollWidth; 232 | viewportHeight = document.documentElement.scrollHeight; 233 | } else if (this.config.mode === 'container' && this.config.dragArea) { 234 | const dragAreaRect = this.config.dragArea.getBoundingClientRect(); 235 | viewportWidth = dragAreaRect.width; 236 | viewportHeight = dragAreaRect.height; 237 | } else { 238 | viewportWidth = window.innerWidth; 239 | viewportHeight = window.innerHeight; 240 | } 241 | 242 | const distanceToLeft = this.element.offsetLeft; 243 | const distanceToRight = viewportWidth - (this.element.offsetLeft + elementRect.width); 244 | const distanceToTop = this.element.offsetTop; 245 | const distanceToBottom = viewportHeight - (this.element.offsetTop + elementRect.height); 246 | 247 | if (this.config.snapMode === 'auto') { 248 | const minDistance = Math.min( 249 | distanceToLeft, 250 | distanceToRight, 251 | distanceToTop, 252 | distanceToBottom 253 | ); 254 | if (minDistance === distanceToLeft) { 255 | this.element.style.left = `${snapBuffer}px`; 256 | } else if (minDistance === distanceToRight) { 257 | this.element.style.left = `${viewportWidth - elementRect.width - snapBuffer}px`; 258 | } else if (minDistance === distanceToTop) { 259 | this.element.style.top = `${snapBuffer}px`; 260 | } else if (minDistance === distanceToBottom) { 261 | this.element.style.top = `${viewportHeight - elementRect.height - snapBuffer}px`; 262 | } 263 | } else if (this.config.snapMode === 'right') { 264 | this.element.style.left = `${viewportWidth - elementRect.width - snapBuffer}px`; 265 | } else if (this.config.snapMode === 'left') { 266 | this.element.style.left = `${snapBuffer}px`; 267 | } else if (this.config.snapMode === 'top') { 268 | this.element.style.top = `${snapBuffer}px`; 269 | } else if (this.config.snapMode === 'bottom') { 270 | this.element.style.top = `${viewportHeight - elementRect.height - snapBuffer}px`; 271 | } 272 | } 273 | 274 | // 恢复 iframe 的指针事件 275 | const iframes = document.getElementsByTagName('iframe'); 276 | for (const iframe of iframes) { 277 | iframe.style.pointerEvents = ''; 278 | } 279 | 280 | this.savePosition(); 281 | 282 | // 移除事件监听器 283 | if (this.touchMoveHandler) { 284 | document.removeEventListener('touchmove', this.touchMoveHandler); 285 | this.touchMoveHandler = null; 286 | } 287 | 288 | if (this.touchEndHandler) { 289 | document.removeEventListener('touchend', this.touchEndHandler); 290 | document.removeEventListener('touchcancel', this.touchEndHandler); 291 | this.touchEndHandler = null; 292 | } 293 | 294 | this.isDragging = false; 295 | 296 | if (this.config.onDragEnd) this.config.onDragEnd(this.element); 297 | } 298 | 299 | savePosition(): void { 300 | if (!this.shouldSave) return; 301 | savePosition(this.element, this.shouldSave); 302 | } 303 | 304 | restorePosition(): void { 305 | restorePosition(this.element, this.shouldSave, { initialPosition: this.initialPosition }); 306 | } 307 | 308 | // 解决vue、React等框架中,重复渲染导致的元素 display 属性被修改的问题 309 | observeElementVisibility(element: HTMLElement) { 310 | this.observerElement = new MutationObserver((mutations) => { 311 | mutations.forEach((mutation) => { 312 | if ( 313 | mutation.attributeName === "style" && 314 | element.style.display === "none" 315 | ) { 316 | // 恢复为可见 317 | element.style.display = "block"; 318 | } 319 | }); 320 | }); 321 | 322 | this.observerElement.observe(element, { 323 | attributes: true, 324 | attributeFilter: ["style"], 325 | }); 326 | } 327 | 328 | // 检测是否为移动设备 329 | static isMobileDevice(): boolean { 330 | return ( 331 | /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || 332 | ('ontouchstart' in window) || 333 | (navigator.maxTouchPoints > 0) 334 | ); 335 | } 336 | 337 | init(): void { 338 | // 设置初始位置 339 | this.restorePosition(); 340 | this.element.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false }); 341 | window.addEventListener('resize', this.updateBounds.bind(this)); 342 | this.updateBounds(); 343 | this.observeElementVisibility(this.element); 344 | } 345 | 346 | destroy(): void { 347 | try { 348 | // 移除元素上的 touchstart 事件监听器 349 | this.element.removeEventListener('touchstart', this.onTouchStart.bind(this)); 350 | 351 | // 如果触摸移动和触摸结束事件仍在监听,移除这些监听器 352 | if (this.touchMoveHandler) { 353 | document.removeEventListener('touchmove', this.touchMoveHandler); 354 | this.touchMoveHandler = null; 355 | } 356 | 357 | if (this.touchEndHandler) { 358 | document.removeEventListener('touchend', this.touchEndHandler); 359 | document.removeEventListener('touchcancel', this.touchEndHandler); 360 | this.touchEndHandler = null; 361 | } 362 | 363 | // 移除窗口的 resize 事件监听器 364 | window.removeEventListener('resize', this.updateBounds); 365 | 366 | // 如果元素的 display 属性被修改,停止监听这些变化 367 | if (this.observerElement) { 368 | this.observerElement.disconnect(); 369 | this.observerElement = null; 370 | } 371 | } catch (error) { 372 | console.warn('Error in destroy method:', error); 373 | } 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /lib/drag-kit.umd.js: -------------------------------------------------------------------------------- 1 | !function(t,i){"object"==typeof exports&&"undefined"!=typeof module?i(exports):"function"==typeof define&&define.amd?define(["exports"],i):i((t="undefined"!=typeof globalThis?globalThis:t||self).DragKit={})}(this,function(t){"use strict";var i=Object.defineProperty,s=(t,s,h)=>((t,s,h)=>s in t?i(t,s,{enumerable:!0,configurable:!0,writable:!0,value:h}):t[s]=h)(t,"symbol"!=typeof s?s+"":s,h);const h="SailingDraggablePositions";function e(t,i){if(!i)return;const s=JSON.parse(localStorage.getItem(h)||"{}"),e=t.id,o={left:t.style.left,top:t.style.top};s[e]=o,localStorage.setItem(h,JSON.stringify(s))}function o(t,i,s){if(i){const i=JSON.parse(localStorage.getItem(h)||"{}"),e=t.id;if(i[e]){const s=i[e];t.style.left=s.left||"0px",t.style.top=s.top||"0px"}else t.style.left=s.initialPosition.x,t.style.top=s.initialPosition.y}else t.style.left=s.initialPosition.x,t.style.top=s.initialPosition.y}const n=class t{constructor(i,h={}){var e,o;s(this,"element"),s(this,"shouldSave"),s(this,"minX"),s(this,"minY"),s(this,"maxX"),s(this,"maxY"),s(this,"diffX"),s(this,"diffY"),s(this,"mouseMoveHandler"),s(this,"mouseUpHandler"),s(this,"observerElement"),s(this,"config"),s(this,"initialPosition"),s(this,"startX"),s(this,"startY"),s(this,"dragThreshold"),this.element=i,this.shouldSave=h.shouldSave||!1,this.minX=0,this.minY=0,this.maxX=window.innerWidth-i.offsetWidth,this.maxY=window.innerHeight-i.offsetHeight,this.diffX=0,this.diffY=0,this.mouseMoveHandler=null,this.mouseUpHandler=null,this.observerElement=null,this.config={...t.defaultOptions,...h},this.initialPosition={x:(null==(e=h.initialPosition)?void 0:e.x)||"0px",y:(null==(o=h.initialPosition)?void 0:o.y)||"0px"},this.startX=0,this.startY=0,this.dragThreshold=5,this.init()}updateBounds(){if(this.config.dragArea){"static"===getComputedStyle(this.config.dragArea).position&&(this.config.dragArea.style.position="relative"),this.element.style.position="absolute";const{width:t,height:i}=this.config.dragArea.getBoundingClientRect();this.maxX=t-this.element.offsetWidth,this.maxY=i-this.element.offsetHeight}else"screen"===this.config.mode?(this.element.style.position="fixed",this.maxX=window.innerWidth-this.element.offsetWidth,this.maxY=window.innerHeight-this.element.offsetHeight):"page"===this.config.mode&&(this.element.style.position="absolute",this.maxX=document.documentElement.scrollWidth-this.element.offsetWidth,this.maxY=document.documentElement.scrollHeight-this.element.offsetHeight)}onMouseDown(t){if(2===t.button)return;t.preventDefault(),t.stopPropagation(),this.startX=t.clientX,this.startY=t.clientY,this.diffX=t.clientX-this.element.offsetLeft,this.diffY=t.clientY-this.element.offsetTop;const i=document.getElementsByTagName("iframe");for(const s of i)s.style.pointerEvents="none";void 0!==this.element.setCapture&&this.element.setCapture(),this.mouseMoveHandler=this.onMouseMove.bind(this),this.mouseUpHandler=this.onMouseUp.bind(this),document.addEventListener("mousemove",this.mouseMoveHandler),document.addEventListener("mouseup",this.mouseUpHandler),this.config.onDragStart&&this.config.onDragStart(this.element)}onMouseMove(t){t.preventDefault();let i=t.clientX-this.diffX,s=t.clientY-this.diffY;"x"===this.config.lockAxis?s=this.element.offsetTop:"y"===this.config.lockAxis&&(i=this.element.offsetLeft),"container"===this.config.mode&&this.config.dragArea?this.updateBounds():"screen"===this.config.mode?(this.maxX=window.innerWidth-this.element.offsetWidth,this.maxY=window.innerHeight-this.element.offsetHeight):"page"===this.config.mode&&(this.maxX=document.documentElement.scrollWidth-this.element.offsetWidth,this.maxY=document.documentElement.scrollHeight-this.element.offsetHeight);const h=this.config.edgeBuffer||0;if(ithis.maxX+h&&(i=this.maxX+h),sthis.maxY+h&&(s=this.maxY+h),this.config.gridSize){const t=this.config.gridSize||1;i=Math.round(i/t)*t,s=Math.round(s/t)*t}this.element.style.left=`${i}px`,this.element.style.top=`${s}px`,this.config.onDrag&&this.config.onDrag(this.element)}stopPreventEvent(t){t.stopPropagation(),t.preventDefault()}disableClickEvent(){this.element.addEventListener("click",this.stopPreventEvent,!0),setTimeout(()=>{this.element.removeEventListener("click",this.stopPreventEvent,!0)},0)}onMouseUp(t){const i=Math.abs(t.clientX-this.startX),s=Math.abs(t.clientY-this.startY);if((i>this.dragThreshold||s>this.dragThreshold)&&this.disableClickEvent(),this.config.snapMode&&"none"!==this.config.snapMode){const t=this.config.edgeBuffer||0,i=this.element.getBoundingClientRect();let s,h;if("screen"===this.config.mode)s=window.innerWidth,h=window.innerHeight;else if("page"===this.config.mode)s=document.documentElement.scrollWidth,h=document.documentElement.scrollHeight;else if("container"===this.config.mode&&this.config.dragArea){const t=this.config.dragArea.getBoundingClientRect();s=t.width,h=t.height}else s=window.innerWidth,h=window.innerHeight;const e=this.element.offsetLeft,o=s-(this.element.offsetLeft+i.width),n=this.element.offsetTop,c=h-(this.element.offsetTop+i.height);if("auto"===this.config.snapMode){const l=Math.min(e,o,n,c);l===e?this.element.style.left=`${t}px`:l===o?this.element.style.left=s-i.width-t+"px":l===n?this.element.style.top=`${t}px`:l===c&&(this.element.style.top=h-i.height-t+"px")}else"right"===this.config.snapMode?this.element.style.left=s-i.width-t+"px":"left"===this.config.snapMode?this.element.style.left=`${t}px`:"top"===this.config.snapMode?this.element.style.top=`${t}px`:"bottom"===this.config.snapMode&&(this.element.style.top=h-i.height-t+"px")}const h=document.getElementsByTagName("iframe");for(const e of h)e.style.pointerEvents="";this.savePosition(),document.removeEventListener("mousemove",this.mouseMoveHandler),document.removeEventListener("mouseup",this.mouseUpHandler),void 0!==this.element.releaseCapture&&this.element.releaseCapture(),this.config.onDragEnd&&this.config.onDragEnd(this.element)}savePosition(){this.shouldSave&&e(this.element,this.shouldSave)}restorePosition(){o(this.element,this.shouldSave,{initialPosition:this.initialPosition})}observeElementVisibility(t){this.observerElement=new MutationObserver(i=>{i.forEach(i=>{"style"===i.attributeName&&"none"===t.style.display&&(t.style.display="block")})}),this.observerElement.observe(t,{attributes:!0,attributeFilter:["style"]})}init(){this.restorePosition(),this.element.onmousedown=this.onMouseDown.bind(this),window.addEventListener("resize",this.updateBounds.bind(this)),this.updateBounds(),this.observeElementVisibility(this.element)}destroy(){try{this.element.onmousedown=null,this.mouseMoveHandler&&(document.removeEventListener("mousemove",this.mouseMoveHandler),this.mouseMoveHandler=null),this.mouseUpHandler&&(document.removeEventListener("mouseup",this.mouseUpHandler),this.mouseUpHandler=null),window.removeEventListener("resize",this.updateBounds),this.observerElement&&(this.observerElement.disconnect(),this.observerElement=null)}catch(t){}}};s(n,"defaultOptions",{shouldSave:!1,onDragStart:void 0,onDrag:void 0,onDragEnd:void 0,dragArea:void 0,lockAxis:void 0,edgeBuffer:0,gridSize:void 0,mode:"screen",snapMode:"none"});let c=n;const l=class t{constructor(i,h={}){var e,o;s(this,"element"),s(this,"shouldSave"),s(this,"minX"),s(this,"minY"),s(this,"maxX"),s(this,"maxY"),s(this,"diffX"),s(this,"diffY"),s(this,"touchMoveHandler"),s(this,"touchEndHandler"),s(this,"observerElement"),s(this,"config"),s(this,"initialPosition"),s(this,"startX"),s(this,"startY"),s(this,"dragThreshold"),s(this,"isDragging"),this.element=i,this.shouldSave=h.shouldSave||!1,this.minX=0,this.minY=0,this.maxX=window.innerWidth-i.offsetWidth,this.maxY=window.innerHeight-i.offsetHeight,this.diffX=0,this.diffY=0,this.touchMoveHandler=null,this.touchEndHandler=null,this.observerElement=null,this.config={...t.defaultOptions,...h},this.initialPosition={x:(null==(e=h.initialPosition)?void 0:e.x)||"0px",y:(null==(o=h.initialPosition)?void 0:o.y)||"0px"},this.startX=0,this.startY=0,this.dragThreshold=5,this.isDragging=!1,this.init()}getEventCoordinates(t){return t.touches&&t.touches.length>0?{x:t.touches[0].clientX,y:t.touches[0].clientY}:t.changedTouches&&t.changedTouches.length>0?{x:t.changedTouches[0].clientX,y:t.changedTouches[0].clientY}:{x:0,y:0}}updateBounds(){if(this.config.dragArea){"static"===getComputedStyle(this.config.dragArea).position&&(this.config.dragArea.style.position="relative"),this.element.style.position="absolute";const{width:t,height:i}=this.config.dragArea.getBoundingClientRect();this.maxX=t-this.element.offsetWidth,this.maxY=i-this.element.offsetHeight}else"screen"===this.config.mode?(this.element.style.position="fixed",this.maxX=window.innerWidth-this.element.offsetWidth,this.maxY=window.innerHeight-this.element.offsetHeight):"page"===this.config.mode&&(this.element.style.position="absolute",this.maxX=document.documentElement.scrollWidth-this.element.offsetWidth,this.maxY=document.documentElement.scrollHeight-this.element.offsetHeight)}onTouchStart(t){if(1!==t.touches.length)return;t.preventDefault(),t.stopPropagation();const i=this.getEventCoordinates(t);this.startX=i.x,this.startY=i.y,this.diffX=i.x-this.element.offsetLeft,this.diffY=i.y-this.element.offsetTop,this.isDragging=!1;const s=document.getElementsByTagName("iframe");for(const h of s)h.style.pointerEvents="none";this.touchMoveHandler=this.onTouchMove.bind(this),this.touchEndHandler=this.onTouchEnd.bind(this),document.addEventListener("touchmove",this.touchMoveHandler,{passive:!1}),document.addEventListener("touchend",this.touchEndHandler,{passive:!1}),document.addEventListener("touchcancel",this.touchEndHandler,{passive:!1}),this.config.onDragStart&&this.config.onDragStart(this.element)}onTouchMove(t){if(1!==t.touches.length)return;t.preventDefault();const i=this.getEventCoordinates(t),s=Math.abs(i.x-this.startX),h=Math.abs(i.y-this.startY);if(!this.isDragging&&(s>this.dragThreshold||h>this.dragThreshold)&&(this.isDragging=!0),!this.isDragging)return;let e=i.x-this.diffX,o=i.y-this.diffY;"x"===this.config.lockAxis?o=this.element.offsetTop:"y"===this.config.lockAxis&&(e=this.element.offsetLeft),"container"===this.config.mode&&this.config.dragArea?this.updateBounds():"screen"===this.config.mode?(this.maxX=window.innerWidth-this.element.offsetWidth,this.maxY=window.innerHeight-this.element.offsetHeight):"page"===this.config.mode&&(this.maxX=document.documentElement.scrollWidth-this.element.offsetWidth,this.maxY=document.documentElement.scrollHeight-this.element.offsetHeight);const n=this.config.edgeBuffer||0;if(ethis.maxX+n&&(e=this.maxX+n),othis.maxY+n&&(o=this.maxY+n),this.config.gridSize){const t=this.config.gridSize||1;e=Math.round(e/t)*t,o=Math.round(o/t)*t}this.element.style.left=`${e}px`,this.element.style.top=`${o}px`,this.config.onDrag&&this.config.onDrag(this.element)}stopPreventEvent(t){t.stopPropagation(),t.preventDefault()}disableClickEvent(){this.element.addEventListener("click",this.stopPreventEvent,!0),setTimeout(()=>{this.element.removeEventListener("click",this.stopPreventEvent,!0)},0)}onTouchEnd(t){const i=this.getEventCoordinates(t),s=Math.abs(i.x-this.startX),h=Math.abs(i.y-this.startY);if((this.isDragging||s>this.dragThreshold||h>this.dragThreshold)&&this.disableClickEvent(),this.config.snapMode&&"none"!==this.config.snapMode&&this.isDragging){const t=this.config.edgeBuffer||0,i=this.element.getBoundingClientRect();let s,h;if("screen"===this.config.mode)s=window.innerWidth,h=window.innerHeight;else if("page"===this.config.mode)s=document.documentElement.scrollWidth,h=document.documentElement.scrollHeight;else if("container"===this.config.mode&&this.config.dragArea){const t=this.config.dragArea.getBoundingClientRect();s=t.width,h=t.height}else s=window.innerWidth,h=window.innerHeight;const e=this.element.offsetLeft,o=s-(this.element.offsetLeft+i.width),n=this.element.offsetTop,c=h-(this.element.offsetTop+i.height);if("auto"===this.config.snapMode){const l=Math.min(e,o,n,c);l===e?this.element.style.left=`${t}px`:l===o?this.element.style.left=s-i.width-t+"px":l===n?this.element.style.top=`${t}px`:l===c&&(this.element.style.top=h-i.height-t+"px")}else"right"===this.config.snapMode?this.element.style.left=s-i.width-t+"px":"left"===this.config.snapMode?this.element.style.left=`${t}px`:"top"===this.config.snapMode?this.element.style.top=`${t}px`:"bottom"===this.config.snapMode&&(this.element.style.top=h-i.height-t+"px")}const e=document.getElementsByTagName("iframe");for(const o of e)o.style.pointerEvents="";this.savePosition(),this.touchMoveHandler&&(document.removeEventListener("touchmove",this.touchMoveHandler),this.touchMoveHandler=null),this.touchEndHandler&&(document.removeEventListener("touchend",this.touchEndHandler),document.removeEventListener("touchcancel",this.touchEndHandler),this.touchEndHandler=null),this.isDragging=!1,this.config.onDragEnd&&this.config.onDragEnd(this.element)}savePosition(){this.shouldSave&&e(this.element,this.shouldSave)}restorePosition(){o(this.element,this.shouldSave,{initialPosition:this.initialPosition})}observeElementVisibility(t){this.observerElement=new MutationObserver(i=>{i.forEach(i=>{"style"===i.attributeName&&"none"===t.style.display&&(t.style.display="block")})}),this.observerElement.observe(t,{attributes:!0,attributeFilter:["style"]})}static isMobileDevice(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||"ontouchstart"in window||navigator.maxTouchPoints>0}init(){this.restorePosition(),this.element.addEventListener("touchstart",this.onTouchStart.bind(this),{passive:!1}),window.addEventListener("resize",this.updateBounds.bind(this)),this.updateBounds(),this.observeElementVisibility(this.element)}destroy(){try{this.element.removeEventListener("touchstart",this.onTouchStart.bind(this)),this.touchMoveHandler&&(document.removeEventListener("touchmove",this.touchMoveHandler),this.touchMoveHandler=null),this.touchEndHandler&&(document.removeEventListener("touchend",this.touchEndHandler),document.removeEventListener("touchcancel",this.touchEndHandler),this.touchEndHandler=null),window.removeEventListener("resize",this.updateBounds),this.observerElement&&(this.observerElement.disconnect(),this.observerElement=null)}catch(t){}}};s(l,"defaultOptions",{shouldSave:!1,onDragStart:void 0,onDrag:void 0,onDragEnd:void 0,dragArea:void 0,lockAxis:void 0,edgeBuffer:0,gridSize:void 0,mode:"screen",snapMode:"none"});let d=l;t.Draggable=c,t.MobileDraggable=d,t.createDraggable=function(t,i={}){const s=document.getElementById(t);return s?(getComputedStyle(s).display,i.mode&&!["screen","page","container"].includes(i.mode)||"container"===i.mode&&!i.dragArea||i.dragArea&&"container"!==i.mode?null:window.self!==window.top?(s.remove(),null):("none"===getComputedStyle(s).display&&(s.style.display="block"),s.dataset.draggableInitialized?null:(s.dataset.draggableInitialized="true",d.isMobileDevice()?new d(s,i):new c(s,i)))):null},Object.defineProperty(t,Symbol.toStringTag,{value:"Module"})}); 2 | -------------------------------------------------------------------------------- /lib/drag-kit.mjs: -------------------------------------------------------------------------------- 1 | var __defProp = Object.defineProperty; 2 | var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; 3 | var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); 4 | const StorageKey = "SailingDraggablePositions"; 5 | function savePosition(element, shouldSave) { 6 | if (!shouldSave) 7 | return; 8 | const savedPositions = JSON.parse(localStorage.getItem(StorageKey) || "{}"); 9 | const key = element.id; 10 | const position = { 11 | left: element.style.left, 12 | top: element.style.top 13 | }; 14 | savedPositions[key] = position; 15 | localStorage.setItem(StorageKey, JSON.stringify(savedPositions)); 16 | } 17 | function restorePosition(element, shouldSave, options) { 18 | if (!shouldSave) { 19 | element.style.left = options.initialPosition.x; 20 | element.style.top = options.initialPosition.y; 21 | } else { 22 | const savedPositions = JSON.parse(localStorage.getItem(StorageKey) || "{}"); 23 | const key = element.id; 24 | if (savedPositions[key]) { 25 | const position = savedPositions[key]; 26 | element.style.left = position.left || "0px"; 27 | element.style.top = position.top || "0px"; 28 | } else { 29 | element.style.left = options.initialPosition.x; 30 | element.style.top = options.initialPosition.y; 31 | } 32 | } 33 | } 34 | const _Draggable = class _Draggable { 35 | constructor(element, options = {}) { 36 | __publicField(this, "element"); 37 | __publicField(this, "shouldSave"); 38 | __publicField(this, "minX"); 39 | __publicField(this, "minY"); 40 | __publicField(this, "maxX"); 41 | __publicField(this, "maxY"); 42 | __publicField(this, "diffX"); 43 | __publicField(this, "diffY"); 44 | __publicField(this, "mouseMoveHandler"); 45 | __publicField(this, "mouseUpHandler"); 46 | __publicField(this, "observerElement"); 47 | __publicField(this, "config"); 48 | __publicField(this, "initialPosition"); 49 | __publicField(this, "startX"); 50 | __publicField(this, "startY"); 51 | __publicField(this, "dragThreshold"); 52 | var _a, _b; 53 | this.element = element; 54 | this.shouldSave = options.shouldSave || false; 55 | this.minX = 0; 56 | this.minY = 0; 57 | this.maxX = window.innerWidth - element.offsetWidth; 58 | this.maxY = window.innerHeight - element.offsetHeight; 59 | this.diffX = 0; 60 | this.diffY = 0; 61 | this.mouseMoveHandler = null; 62 | this.mouseUpHandler = null; 63 | this.observerElement = null; 64 | this.config = { ..._Draggable.defaultOptions, ...options }; 65 | this.initialPosition = { 66 | x: ((_a = options.initialPosition) == null ? void 0 : _a.x) || "0px", 67 | y: ((_b = options.initialPosition) == null ? void 0 : _b.y) || "0px" 68 | }; 69 | this.startX = 0; 70 | this.startY = 0; 71 | this.dragThreshold = 5; 72 | this.init(); 73 | } 74 | updateBounds() { 75 | if (this.config.dragArea) { 76 | if (getComputedStyle(this.config.dragArea).position === "static") { 77 | this.config.dragArea.style.position = "relative"; 78 | } 79 | this.element.style.position = "absolute"; 80 | const { width, height } = this.config.dragArea.getBoundingClientRect(); 81 | this.maxX = width - this.element.offsetWidth; 82 | this.maxY = height - this.element.offsetHeight; 83 | } else if (this.config.mode === "screen") { 84 | this.element.style.position = "fixed"; 85 | this.maxX = window.innerWidth - this.element.offsetWidth; 86 | this.maxY = window.innerHeight - this.element.offsetHeight; 87 | } else if (this.config.mode === "page") { 88 | this.element.style.position = "absolute"; 89 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 90 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 91 | } 92 | } 93 | onMouseDown(event) { 94 | if (event.button === 2) { 95 | return; 96 | } 97 | event.preventDefault(); 98 | event.stopPropagation(); 99 | this.startX = event.clientX; 100 | this.startY = event.clientY; 101 | this.diffX = event.clientX - this.element.offsetLeft; 102 | this.diffY = event.clientY - this.element.offsetTop; 103 | const iframes = document.getElementsByTagName("iframe"); 104 | for (const iframe of iframes) { 105 | iframe.style.pointerEvents = "none"; 106 | } 107 | if (typeof this.element.setCapture !== "undefined") { 108 | this.element.setCapture(); 109 | } 110 | this.mouseMoveHandler = this.onMouseMove.bind(this); 111 | this.mouseUpHandler = this.onMouseUp.bind(this); 112 | document.addEventListener("mousemove", this.mouseMoveHandler); 113 | document.addEventListener("mouseup", this.mouseUpHandler); 114 | if (this.config.onDragStart) 115 | this.config.onDragStart(this.element); 116 | } 117 | onMouseMove(event) { 118 | event.preventDefault(); 119 | let moveX = event.clientX - this.diffX; 120 | let moveY = event.clientY - this.diffY; 121 | if (this.config.lockAxis === "x") { 122 | moveY = this.element.offsetTop; 123 | } else if (this.config.lockAxis === "y") { 124 | moveX = this.element.offsetLeft; 125 | } 126 | if (this.config.mode === "container" && this.config.dragArea) { 127 | this.updateBounds(); 128 | } else if (this.config.mode === "screen") { 129 | this.maxX = window.innerWidth - this.element.offsetWidth; 130 | this.maxY = window.innerHeight - this.element.offsetHeight; 131 | } else if (this.config.mode === "page") { 132 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 133 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 134 | } 135 | const edgeBuffer = this.config.edgeBuffer || 0; 136 | if (moveX < this.minX - edgeBuffer) 137 | moveX = this.minX - edgeBuffer; 138 | if (moveX > this.maxX + edgeBuffer) 139 | moveX = this.maxX + edgeBuffer; 140 | if (moveY < this.minY - edgeBuffer) 141 | moveY = this.minY - edgeBuffer; 142 | if (moveY > this.maxY + edgeBuffer) 143 | moveY = this.maxY + edgeBuffer; 144 | if (this.config.gridSize) { 145 | const gridSize = this.config.gridSize || 1; 146 | moveX = Math.round(moveX / gridSize) * gridSize; 147 | moveY = Math.round(moveY / gridSize) * gridSize; 148 | } 149 | this.element.style.left = `${moveX}px`; 150 | this.element.style.top = `${moveY}px`; 151 | if (this.config.onDrag) 152 | this.config.onDrag(this.element); 153 | } 154 | stopPreventEvent(event) { 155 | event.stopPropagation(); 156 | event.preventDefault(); 157 | } 158 | disableClickEvent() { 159 | this.element.addEventListener("click", this.stopPreventEvent, true); 160 | setTimeout(() => { 161 | this.element.removeEventListener("click", this.stopPreventEvent, true); 162 | }, 0); 163 | } 164 | onMouseUp(event) { 165 | const moveX = Math.abs(event.clientX - this.startX); 166 | const moveY = Math.abs(event.clientY - this.startY); 167 | if (moveX > this.dragThreshold || moveY > this.dragThreshold) { 168 | this.disableClickEvent(); 169 | } 170 | if (this.config.snapMode && this.config.snapMode !== "none") { 171 | const snapBuffer = this.config.edgeBuffer || 0; 172 | const elementRect = this.element.getBoundingClientRect(); 173 | let viewportWidth, viewportHeight; 174 | if (this.config.mode === "screen") { 175 | viewportWidth = window.innerWidth; 176 | viewportHeight = window.innerHeight; 177 | } else if (this.config.mode === "page") { 178 | viewportWidth = document.documentElement.scrollWidth; 179 | viewportHeight = document.documentElement.scrollHeight; 180 | } else if (this.config.mode === "container" && this.config.dragArea) { 181 | const dragAreaRect = this.config.dragArea.getBoundingClientRect(); 182 | viewportWidth = dragAreaRect.width; 183 | viewportHeight = dragAreaRect.height; 184 | } else { 185 | viewportWidth = window.innerWidth; 186 | viewportHeight = window.innerHeight; 187 | } 188 | const distanceToLeft = this.element.offsetLeft; 189 | const distanceToRight = viewportWidth - (this.element.offsetLeft + elementRect.width); 190 | const distanceToTop = this.element.offsetTop; 191 | const distanceToBottom = viewportHeight - (this.element.offsetTop + elementRect.height); 192 | if (this.config.snapMode === "auto") { 193 | const minDistance = Math.min(distanceToLeft, distanceToRight, distanceToTop, distanceToBottom); 194 | if (minDistance === distanceToLeft) { 195 | this.element.style.left = `${snapBuffer}px`; 196 | } else if (minDistance === distanceToRight) { 197 | this.element.style.left = `${viewportWidth - elementRect.width - snapBuffer}px`; 198 | } else if (minDistance === distanceToTop) { 199 | this.element.style.top = `${snapBuffer}px`; 200 | } else if (minDistance === distanceToBottom) { 201 | this.element.style.top = `${viewportHeight - elementRect.height - snapBuffer}px`; 202 | } 203 | } else if (this.config.snapMode === "right") { 204 | this.element.style.left = `${viewportWidth - elementRect.width - snapBuffer}px`; 205 | } else if (this.config.snapMode === "left") { 206 | this.element.style.left = `${snapBuffer}px`; 207 | } else if (this.config.snapMode === "top") { 208 | this.element.style.top = `${snapBuffer}px`; 209 | } else if (this.config.snapMode === "bottom") { 210 | this.element.style.top = `${viewportHeight - elementRect.height - snapBuffer}px`; 211 | } 212 | } 213 | const iframes = document.getElementsByTagName("iframe"); 214 | for (const iframe of iframes) { 215 | iframe.style.pointerEvents = ""; 216 | } 217 | this.savePosition(); 218 | document.removeEventListener("mousemove", this.mouseMoveHandler); 219 | document.removeEventListener("mouseup", this.mouseUpHandler); 220 | if (typeof this.element.releaseCapture !== "undefined") { 221 | this.element.releaseCapture(); 222 | } 223 | if (this.config.onDragEnd) 224 | this.config.onDragEnd(this.element); 225 | } 226 | savePosition() { 227 | if (!this.shouldSave) 228 | return; 229 | savePosition(this.element, this.shouldSave); 230 | } 231 | restorePosition() { 232 | restorePosition(this.element, this.shouldSave, { initialPosition: this.initialPosition }); 233 | } 234 | // 解决vue、React等框架中,重复渲染导致的元素 display 属性被修改的问题 235 | observeElementVisibility(element) { 236 | this.observerElement = new MutationObserver((mutations) => { 237 | mutations.forEach((mutation) => { 238 | if (mutation.attributeName === "style" && element.style.display === "none") { 239 | element.style.display = "block"; 240 | } 241 | }); 242 | }); 243 | this.observerElement.observe(element, { 244 | attributes: true, 245 | attributeFilter: ["style"] 246 | }); 247 | } 248 | init() { 249 | this.restorePosition(); 250 | this.element.onmousedown = this.onMouseDown.bind(this); 251 | window.addEventListener("resize", this.updateBounds.bind(this)); 252 | this.updateBounds(); 253 | this.observeElementVisibility(this.element); 254 | } 255 | destroy() { 256 | try { 257 | this.element.onmousedown = null; 258 | if (this.mouseMoveHandler) { 259 | document.removeEventListener("mousemove", this.mouseMoveHandler); 260 | this.mouseMoveHandler = null; 261 | } 262 | if (this.mouseUpHandler) { 263 | document.removeEventListener("mouseup", this.mouseUpHandler); 264 | this.mouseUpHandler = null; 265 | } 266 | window.removeEventListener("resize", this.updateBounds); 267 | if (this.observerElement) { 268 | this.observerElement.disconnect(); 269 | this.observerElement = null; 270 | } 271 | } catch (error) { 272 | console.warn("Error in destroy method:", error); 273 | } 274 | } 275 | }; 276 | __publicField(_Draggable, "defaultOptions", { 277 | shouldSave: false, 278 | onDragStart: void 0, 279 | onDrag: void 0, 280 | onDragEnd: void 0, 281 | dragArea: void 0, 282 | lockAxis: void 0, 283 | edgeBuffer: 0, 284 | gridSize: void 0, 285 | mode: "screen", 286 | snapMode: "none" 287 | }); 288 | let Draggable = _Draggable; 289 | const _MobileDraggable = class _MobileDraggable { 290 | constructor(element, options = {}) { 291 | __publicField(this, "element"); 292 | __publicField(this, "shouldSave"); 293 | __publicField(this, "minX"); 294 | __publicField(this, "minY"); 295 | __publicField(this, "maxX"); 296 | __publicField(this, "maxY"); 297 | __publicField(this, "diffX"); 298 | __publicField(this, "diffY"); 299 | __publicField(this, "touchMoveHandler"); 300 | __publicField(this, "touchEndHandler"); 301 | __publicField(this, "observerElement"); 302 | __publicField(this, "config"); 303 | __publicField(this, "initialPosition"); 304 | __publicField(this, "startX"); 305 | __publicField(this, "startY"); 306 | __publicField(this, "dragThreshold"); 307 | __publicField(this, "isDragging"); 308 | var _a, _b; 309 | this.element = element; 310 | this.shouldSave = options.shouldSave || false; 311 | this.minX = 0; 312 | this.minY = 0; 313 | this.maxX = window.innerWidth - element.offsetWidth; 314 | this.maxY = window.innerHeight - element.offsetHeight; 315 | this.diffX = 0; 316 | this.diffY = 0; 317 | this.touchMoveHandler = null; 318 | this.touchEndHandler = null; 319 | this.observerElement = null; 320 | this.config = { ..._MobileDraggable.defaultOptions, ...options }; 321 | this.initialPosition = { 322 | x: ((_a = options.initialPosition) == null ? void 0 : _a.x) || "0px", 323 | y: ((_b = options.initialPosition) == null ? void 0 : _b.y) || "0px" 324 | }; 325 | this.startX = 0; 326 | this.startY = 0; 327 | this.dragThreshold = 5; 328 | this.isDragging = false; 329 | this.init(); 330 | } 331 | // 统一获取坐标的方法 332 | getEventCoordinates(event) { 333 | if (event.touches && event.touches.length > 0) { 334 | return { x: event.touches[0].clientX, y: event.touches[0].clientY }; 335 | } 336 | if (event.changedTouches && event.changedTouches.length > 0) { 337 | return { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }; 338 | } 339 | return { x: 0, y: 0 }; 340 | } 341 | updateBounds() { 342 | if (this.config.dragArea) { 343 | if (getComputedStyle(this.config.dragArea).position === "static") { 344 | this.config.dragArea.style.position = "relative"; 345 | } 346 | this.element.style.position = "absolute"; 347 | const { width, height } = this.config.dragArea.getBoundingClientRect(); 348 | this.maxX = width - this.element.offsetWidth; 349 | this.maxY = height - this.element.offsetHeight; 350 | } else if (this.config.mode === "screen") { 351 | this.element.style.position = "fixed"; 352 | this.maxX = window.innerWidth - this.element.offsetWidth; 353 | this.maxY = window.innerHeight - this.element.offsetHeight; 354 | } else if (this.config.mode === "page") { 355 | this.element.style.position = "absolute"; 356 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 357 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 358 | } 359 | } 360 | onTouchStart(event) { 361 | if (event.touches.length !== 1) { 362 | return; 363 | } 364 | event.preventDefault(); 365 | event.stopPropagation(); 366 | const coords = this.getEventCoordinates(event); 367 | this.startX = coords.x; 368 | this.startY = coords.y; 369 | this.diffX = coords.x - this.element.offsetLeft; 370 | this.diffY = coords.y - this.element.offsetTop; 371 | this.isDragging = false; 372 | const iframes = document.getElementsByTagName("iframe"); 373 | for (const iframe of iframes) { 374 | iframe.style.pointerEvents = "none"; 375 | } 376 | this.touchMoveHandler = this.onTouchMove.bind(this); 377 | this.touchEndHandler = this.onTouchEnd.bind(this); 378 | document.addEventListener("touchmove", this.touchMoveHandler, { passive: false }); 379 | document.addEventListener("touchend", this.touchEndHandler, { passive: false }); 380 | document.addEventListener("touchcancel", this.touchEndHandler, { passive: false }); 381 | if (this.config.onDragStart) 382 | this.config.onDragStart(this.element); 383 | } 384 | onTouchMove(event) { 385 | if (event.touches.length !== 1) { 386 | return; 387 | } 388 | event.preventDefault(); 389 | const coords = this.getEventCoordinates(event); 390 | const moveX = Math.abs(coords.x - this.startX); 391 | const moveY = Math.abs(coords.y - this.startY); 392 | if (!this.isDragging && (moveX > this.dragThreshold || moveY > this.dragThreshold)) { 393 | this.isDragging = true; 394 | } 395 | if (!this.isDragging) { 396 | return; 397 | } 398 | let newX = coords.x - this.diffX; 399 | let newY = coords.y - this.diffY; 400 | if (this.config.lockAxis === "x") { 401 | newY = this.element.offsetTop; 402 | } else if (this.config.lockAxis === "y") { 403 | newX = this.element.offsetLeft; 404 | } 405 | if (this.config.mode === "container" && this.config.dragArea) { 406 | this.updateBounds(); 407 | } else if (this.config.mode === "screen") { 408 | this.maxX = window.innerWidth - this.element.offsetWidth; 409 | this.maxY = window.innerHeight - this.element.offsetHeight; 410 | } else if (this.config.mode === "page") { 411 | this.maxX = document.documentElement.scrollWidth - this.element.offsetWidth; 412 | this.maxY = document.documentElement.scrollHeight - this.element.offsetHeight; 413 | } 414 | const edgeBuffer = this.config.edgeBuffer || 0; 415 | if (newX < this.minX - edgeBuffer) 416 | newX = this.minX - edgeBuffer; 417 | if (newX > this.maxX + edgeBuffer) 418 | newX = this.maxX + edgeBuffer; 419 | if (newY < this.minY - edgeBuffer) 420 | newY = this.minY - edgeBuffer; 421 | if (newY > this.maxY + edgeBuffer) 422 | newY = this.maxY + edgeBuffer; 423 | if (this.config.gridSize) { 424 | const gridSize = this.config.gridSize || 1; 425 | newX = Math.round(newX / gridSize) * gridSize; 426 | newY = Math.round(newY / gridSize) * gridSize; 427 | } 428 | this.element.style.left = `${newX}px`; 429 | this.element.style.top = `${newY}px`; 430 | if (this.config.onDrag) 431 | this.config.onDrag(this.element); 432 | } 433 | stopPreventEvent(event) { 434 | event.stopPropagation(); 435 | event.preventDefault(); 436 | } 437 | disableClickEvent() { 438 | this.element.addEventListener("click", this.stopPreventEvent, true); 439 | setTimeout(() => { 440 | this.element.removeEventListener("click", this.stopPreventEvent, true); 441 | }, 0); 442 | } 443 | onTouchEnd(event) { 444 | const coords = this.getEventCoordinates(event); 445 | const moveX = Math.abs(coords.x - this.startX); 446 | const moveY = Math.abs(coords.y - this.startY); 447 | if (this.isDragging || moveX > this.dragThreshold || moveY > this.dragThreshold) { 448 | this.disableClickEvent(); 449 | } 450 | if (this.config.snapMode && this.config.snapMode !== "none" && this.isDragging) { 451 | const snapBuffer = this.config.edgeBuffer || 0; 452 | const elementRect = this.element.getBoundingClientRect(); 453 | let viewportWidth, viewportHeight; 454 | if (this.config.mode === "screen") { 455 | viewportWidth = window.innerWidth; 456 | viewportHeight = window.innerHeight; 457 | } else if (this.config.mode === "page") { 458 | viewportWidth = document.documentElement.scrollWidth; 459 | viewportHeight = document.documentElement.scrollHeight; 460 | } else if (this.config.mode === "container" && this.config.dragArea) { 461 | const dragAreaRect = this.config.dragArea.getBoundingClientRect(); 462 | viewportWidth = dragAreaRect.width; 463 | viewportHeight = dragAreaRect.height; 464 | } else { 465 | viewportWidth = window.innerWidth; 466 | viewportHeight = window.innerHeight; 467 | } 468 | const distanceToLeft = this.element.offsetLeft; 469 | const distanceToRight = viewportWidth - (this.element.offsetLeft + elementRect.width); 470 | const distanceToTop = this.element.offsetTop; 471 | const distanceToBottom = viewportHeight - (this.element.offsetTop + elementRect.height); 472 | if (this.config.snapMode === "auto") { 473 | const minDistance = Math.min(distanceToLeft, distanceToRight, distanceToTop, distanceToBottom); 474 | if (minDistance === distanceToLeft) { 475 | this.element.style.left = `${snapBuffer}px`; 476 | } else if (minDistance === distanceToRight) { 477 | this.element.style.left = `${viewportWidth - elementRect.width - snapBuffer}px`; 478 | } else if (minDistance === distanceToTop) { 479 | this.element.style.top = `${snapBuffer}px`; 480 | } else if (minDistance === distanceToBottom) { 481 | this.element.style.top = `${viewportHeight - elementRect.height - snapBuffer}px`; 482 | } 483 | } else if (this.config.snapMode === "right") { 484 | this.element.style.left = `${viewportWidth - elementRect.width - snapBuffer}px`; 485 | } else if (this.config.snapMode === "left") { 486 | this.element.style.left = `${snapBuffer}px`; 487 | } else if (this.config.snapMode === "top") { 488 | this.element.style.top = `${snapBuffer}px`; 489 | } else if (this.config.snapMode === "bottom") { 490 | this.element.style.top = `${viewportHeight - elementRect.height - snapBuffer}px`; 491 | } 492 | } 493 | const iframes = document.getElementsByTagName("iframe"); 494 | for (const iframe of iframes) { 495 | iframe.style.pointerEvents = ""; 496 | } 497 | this.savePosition(); 498 | if (this.touchMoveHandler) { 499 | document.removeEventListener("touchmove", this.touchMoveHandler); 500 | this.touchMoveHandler = null; 501 | } 502 | if (this.touchEndHandler) { 503 | document.removeEventListener("touchend", this.touchEndHandler); 504 | document.removeEventListener("touchcancel", this.touchEndHandler); 505 | this.touchEndHandler = null; 506 | } 507 | this.isDragging = false; 508 | if (this.config.onDragEnd) 509 | this.config.onDragEnd(this.element); 510 | } 511 | savePosition() { 512 | if (!this.shouldSave) 513 | return; 514 | savePosition(this.element, this.shouldSave); 515 | } 516 | restorePosition() { 517 | restorePosition(this.element, this.shouldSave, { initialPosition: this.initialPosition }); 518 | } 519 | // 解决vue、React等框架中,重复渲染导致的元素 display 属性被修改的问题 520 | observeElementVisibility(element) { 521 | this.observerElement = new MutationObserver((mutations) => { 522 | mutations.forEach((mutation) => { 523 | if (mutation.attributeName === "style" && element.style.display === "none") { 524 | element.style.display = "block"; 525 | } 526 | }); 527 | }); 528 | this.observerElement.observe(element, { 529 | attributes: true, 530 | attributeFilter: ["style"] 531 | }); 532 | } 533 | // 检测是否为移动设备 534 | static isMobileDevice() { 535 | return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || "ontouchstart" in window || navigator.maxTouchPoints > 0; 536 | } 537 | init() { 538 | this.restorePosition(); 539 | this.element.addEventListener("touchstart", this.onTouchStart.bind(this), { passive: false }); 540 | window.addEventListener("resize", this.updateBounds.bind(this)); 541 | this.updateBounds(); 542 | this.observeElementVisibility(this.element); 543 | } 544 | destroy() { 545 | try { 546 | this.element.removeEventListener("touchstart", this.onTouchStart.bind(this)); 547 | if (this.touchMoveHandler) { 548 | document.removeEventListener("touchmove", this.touchMoveHandler); 549 | this.touchMoveHandler = null; 550 | } 551 | if (this.touchEndHandler) { 552 | document.removeEventListener("touchend", this.touchEndHandler); 553 | document.removeEventListener("touchcancel", this.touchEndHandler); 554 | this.touchEndHandler = null; 555 | } 556 | window.removeEventListener("resize", this.updateBounds); 557 | if (this.observerElement) { 558 | this.observerElement.disconnect(); 559 | this.observerElement = null; 560 | } 561 | } catch (error) { 562 | console.warn("Error in destroy method:", error); 563 | } 564 | } 565 | }; 566 | __publicField(_MobileDraggable, "defaultOptions", { 567 | shouldSave: false, 568 | onDragStart: void 0, 569 | onDrag: void 0, 570 | onDragEnd: void 0, 571 | dragArea: void 0, 572 | lockAxis: void 0, 573 | edgeBuffer: 0, 574 | gridSize: void 0, 575 | mode: "screen", 576 | snapMode: "none" 577 | }); 578 | let MobileDraggable = _MobileDraggable; 579 | function createDraggable(elementId, options = {}) { 580 | const element = document.getElementById(elementId); 581 | if (!element) { 582 | console.error(`Element with id ${elementId} not found.`); 583 | return null; 584 | } 585 | if (getComputedStyle(element).display !== "none") { 586 | console.error(`Element with id ${elementId} is visible. It's recommended to set display: none for proper initial positioning.`); 587 | } 588 | if (options.mode && !["screen", "page", "container"].includes(options.mode)) { 589 | console.error('Invalid mode option. Valid options are "screen", "page", or "container".'); 590 | return null; 591 | } 592 | if (options.mode === "container" && !options.dragArea || options.dragArea && options.mode !== "container") { 593 | console.error("Draggable container requires a dragArea option."); 594 | return null; 595 | } 596 | if (window.self !== window.top) { 597 | console.warn("Draggable is disabled inside iframes."); 598 | element.remove(); 599 | return null; 600 | } else { 601 | if (getComputedStyle(element).display === "none") { 602 | element.style.display = "block"; 603 | } 604 | } 605 | if (element.dataset.draggableInitialized) { 606 | console.warn(`Element with id ${elementId} is already draggable.`); 607 | return null; 608 | } 609 | element.dataset.draggableInitialized = "true"; 610 | if (MobileDraggable.isMobileDevice()) { 611 | console.log("Mobile device detected, using MobileDraggable"); 612 | return new MobileDraggable(element, options); 613 | } else { 614 | console.log("Desktop device detected, using Draggable"); 615 | return new Draggable(element, options); 616 | } 617 | } 618 | export { 619 | Draggable, 620 | MobileDraggable, 621 | createDraggable 622 | }; 623 | --------------------------------------------------------------------------------