import { computed, ref, type Ref } from 'vue' const MIN_WIDTH = 288 const MIN_HEIGHT = 200 const getStorageKey = (key: string) => `fds-modal-${key}` export function useModalDragResize(options: { movable: Ref resizable: Ref elementRef: Ref heading: Ref }) { const isDragging = ref(false) const isResizing = ref(false) const hasBeenDragged = ref(false) const hasBeenResized = ref(false) const modalPosition = ref<{ left: number; top: number } | null>(null) const modalSize = ref<{ width: number; height: number } | null>(null) const dragOffset = ref({ x: 0, y: 0 }) const resizeDirection = ref('') const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0, left: 0, top: 0 }) const lastInteractionEnd = ref(0) const isCustomPositioned = computed(() => hasBeenDragged.value || hasBeenResized.value) const canPersist = () => (options.movable.value || options.resizable.value) && !!options.heading.value && typeof window !== 'undefined' const save = () => { if (!canPersist()) return const data: { position?: { left: number; top: number }; size?: { width: number; height: number } } = {} if (modalPosition.value) data.position = modalPosition.value if (modalSize.value) data.size = modalSize.value try { window.localStorage.setItem(getStorageKey(options.heading.value), JSON.stringify(data)) } catch { // localStorage may be full or unavailable } } const restore = () => { if (!canPersist()) return false try { const raw = window.localStorage.getItem(getStorageKey(options.heading.value)) if (!raw) return false const data = JSON.parse(raw) if (data.position) { modalPosition.value = data.position hasBeenDragged.value = true } if (data.size) { modalSize.value = data.size hasBeenResized.value = true } return true } catch { return false } } const style = computed(() => { if (!isCustomPositioned.value) return undefined const s: Record = {} if (modalPosition.value) { s.left = `${modalPosition.value.left}px` s.top = `${modalPosition.value.top}px` } if (modalSize.value) { s.width = `${modalSize.value.width}px` s.height = `${modalSize.value.height}px` } return s }) const preventSelection = (e: Event) => e.preventDefault() const initializeCustomPosition = () => { const el = options.elementRef.value if (!el) return const rect = el.getBoundingClientRect() if (!modalPosition.value) { modalPosition.value = { left: rect.left, top: rect.top } } if (!modalSize.value) { modalSize.value = { width: rect.width, height: rect.height } } } // Drag handlers const onDragStart = (e: MouseEvent) => { if (!options.movable.value || isResizing.value) return const target = e.target as HTMLElement if (target.closest('button, a, input, textarea, select')) return e.preventDefault() initializeCustomPosition() hasBeenDragged.value = true isDragging.value = true dragOffset.value = { x: e.clientX - modalPosition.value!.left, y: e.clientY - modalPosition.value!.top, } document.addEventListener('selectstart', preventSelection) document.addEventListener('mousemove', onDragMove) document.addEventListener('mouseup', onDragEnd) } const onDragMove = (e: MouseEvent) => { if (!isDragging.value) return e.preventDefault() modalPosition.value = { left: e.clientX - dragOffset.value.x, top: e.clientY - dragOffset.value.y, } } const onDragEnd = () => { isDragging.value = false lastInteractionEnd.value = Date.now() document.removeEventListener('selectstart', preventSelection) document.removeEventListener('mousemove', onDragMove) document.removeEventListener('mouseup', onDragEnd) save() } // Resize handlers const onResizeStart = (e: MouseEvent, direction: string) => { if (!options.resizable.value) return e.preventDefault() e.stopPropagation() initializeCustomPosition() hasBeenResized.value = true isResizing.value = true resizeDirection.value = direction resizeStart.value = { x: e.clientX, y: e.clientY, width: modalSize.value!.width, height: modalSize.value!.height, left: modalPosition.value!.left, top: modalPosition.value!.top, } document.addEventListener('selectstart', preventSelection) document.addEventListener('mousemove', onResizeMove) document.addEventListener('mouseup', onResizeEnd) } const onResizeMove = (e: MouseEvent) => { if (!isResizing.value) return e.preventDefault() const dx = e.clientX - resizeStart.value.x const dy = e.clientY - resizeStart.value.y const dir = resizeDirection.value let newWidth = resizeStart.value.width let newHeight = resizeStart.value.height let newLeft = resizeStart.value.left let newTop = resizeStart.value.top if (dir.includes('e')) newWidth = Math.max(MIN_WIDTH, resizeStart.value.width + dx) if (dir.includes('w')) { const w = Math.max(MIN_WIDTH, resizeStart.value.width - dx) newLeft = resizeStart.value.left + (resizeStart.value.width - w) newWidth = w } if (dir.includes('s')) newHeight = Math.max(MIN_HEIGHT, resizeStart.value.height + dy) if (dir.includes('n')) { const h = Math.max(MIN_HEIGHT, resizeStart.value.height - dy) newTop = resizeStart.value.top + (resizeStart.value.height - h) newHeight = h } modalPosition.value = { left: newLeft, top: newTop } modalSize.value = { width: newWidth, height: newHeight } } const onResizeEnd = () => { isResizing.value = false resizeDirection.value = '' lastInteractionEnd.value = Date.now() document.removeEventListener('selectstart', preventSelection) document.removeEventListener('mousemove', onResizeMove) document.removeEventListener('mouseup', onResizeEnd) save() } // Utilities const reset = () => { hasBeenDragged.value = false hasBeenResized.value = false isDragging.value = false isResizing.value = false modalPosition.value = null modalSize.value = null } const cleanup = () => { document.removeEventListener('selectstart', preventSelection) document.removeEventListener('mousemove', onDragMove) document.removeEventListener('mouseup', onDragEnd) document.removeEventListener('mousemove', onResizeMove) document.removeEventListener('mouseup', onResizeEnd) } const shouldBlockBackdropClick = () => Date.now() - lastInteractionEnd.value < 200 return { isCustomPositioned, style, onDragStart, onResizeStart, reset, restore, cleanup, shouldBlockBackdropClick, } }