import { computed, onMounted, onUnmounted, ref } from 'vue' const DRAG_THRESHOLD = 6 const POSITION_VERSION = 1 type FabPosition = { x: number; y: number } type SavedFabOffset = { version: number dx: number dy: number } type DraggableFloatingOptions = { width?: number height?: number /** فاصله از پایینِ نوار منو + montzar (px) — مثل تصویر */ defaultBottomGap?: number defaultRightOffset?: number } function readBottomChromeHeight(): number { const styles = getComputedStyle(document.documentElement) const direct = parseFloat(styles.getPropertyValue('--app-bottom-chrome-height')) if (!Number.isNaN(direct) && direct > 0) { return direct } const nav = parseFloat(styles.getPropertyValue('--app-nav-height')) || 80 const creditOffset = parseFloat(styles.getPropertyValue('--app-credit-footer-offset')) if (!Number.isNaN(creditOffset) && creditOffset > 0) { return nav + creditOffset } const probe = document.createElement('div') probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;height:var(--app-bottom-chrome-height)' document.body.appendChild(probe) const measured = probe.getBoundingClientRect().height document.body.removeChild(probe) return measured > 0 ? measured : nav + 28 } export function useDraggableFloatingButton( storageKey: string, options: DraggableFloatingOptions = {} ) { const width = options.width ?? 96 const height = options.height ?? 44 const defaultBottomGap = options.defaultBottomGap ?? 12 const defaultRightOffset = options.defaultRightOffset ?? 16 const pos = ref({ x: 0, y: 0 }) const dragging = ref(false) let pointerId: number | null = null let startX = 0 let startY = 0 let originX = 0 let originY = 0 let hasMoved = false const fabStyle = computed(() => ({ left: `${pos.value.x}px`, top: `${pos.value.y}px`, width: `${width}px`, touchAction: 'none' })) function getViewportWidth() { return Math.min(window.innerWidth, 426) } function getHorizontalOffset() { const vw = window.innerWidth const contentWidth = getViewportWidth() return Math.max(0, (vw - contentWidth) / 2) } function getDefaultPosition(): FabPosition { const vh = window.innerHeight const offsetX = getHorizontalOffset() const contentWidth = getViewportWidth() const bottomChrome = readBottomChromeHeight() return { x: offsetX + contentWidth - width - defaultRightOffset, y: vh - bottomChrome - defaultBottomGap - height } } function applySavedOffset(dx: number, dy: number) { const base = getDefaultPosition() pos.value = { x: base.x + dx, y: base.y + dy } clampToViewport() } function clampToViewport() { const vh = window.innerHeight const offsetX = getHorizontalOffset() const contentWidth = getViewportWidth() const minX = offsetX const maxX = offsetX + contentWidth - width pos.value = { x: Math.max(minX, Math.min(maxX, pos.value.x)), y: Math.max(0, Math.min(vh - height, pos.value.y)) } } function loadPosition() { try { const raw = localStorage.getItem(storageKey) if (!raw) return false const parsed = JSON.parse(raw) as SavedFabOffset if ( parsed?.version === POSITION_VERSION && typeof parsed.dx === 'number' && typeof parsed.dy === 'number' ) { applySavedOffset(parsed.dx, parsed.dy) return true } } catch { // ignore } return false } function savePosition() { const base = getDefaultPosition() const payload: SavedFabOffset = { version: POSITION_VERSION, dx: pos.value.x - base.x, dy: pos.value.y - base.y } localStorage.setItem(storageKey, JSON.stringify(payload)) } function resetToDefault() { pos.value = getDefaultPosition() } function handleResize() { try { const raw = localStorage.getItem(storageKey) if (raw) { const parsed = JSON.parse(raw) as SavedFabOffset if ( parsed?.version === POSITION_VERSION && typeof parsed.dx === 'number' && typeof parsed.dy === 'number' ) { applySavedOffset(parsed.dx, parsed.dy) return } } } catch { // ignore } resetToDefault() } function handlePointerDown(event: PointerEvent) { dragging.value = true hasMoved = false pointerId = event.pointerId startX = event.clientX startY = event.clientY originX = pos.value.x originY = pos.value.y ;(event.currentTarget as HTMLElement)?.setPointerCapture?.(event.pointerId) } function handlePointerMove(event: PointerEvent) { if (!dragging.value || pointerId !== event.pointerId) return const dx = event.clientX - startX const dy = event.clientY - startY if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) { hasMoved = true } pos.value = { x: originX + dx, y: originY + dy } clampToViewport() } function finishPointer(event: PointerEvent) { if (!dragging.value || pointerId !== event.pointerId) return false dragging.value = false pointerId = null savePosition() const clicked = !hasMoved hasMoved = false return clicked } function handlePointerUp(event: PointerEvent) { return finishPointer(event) } function handlePointerCancel(event: PointerEvent) { finishPointer(event) return false } onMounted(() => { if (!loadPosition()) { resetToDefault() } window.addEventListener('resize', handleResize) }) onUnmounted(() => { window.removeEventListener('resize', handleResize) }) return { dragging, fabStyle, handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel, resetToDefault } }