import { computed, onMounted, onUnmounted, ref } from 'vue' const DRAG_THRESHOLD = 6 /** v2: دسکتاپ از کنار سایدبار تا انتهای viewport (نه ستون ۴۲۶px) */ const POSITION_VERSION = 2 const MOBILE_CONTENT_MAX = 426 type FabPosition = { x: number; y: number } type SavedFabOffset = { version: number dx: number dy: number } type HorizontalBounds = { offsetX: number contentWidth: number } type DraggableFloatingOptions = { width?: number height?: number 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 } function isDesktopLayout(): boolean { const el = document.documentElement return ( el.classList.contains('app-layout--desktop') || el.getAttribute('data-layout') === 'desktop' ) } function readCssLengthPx(varName: string): number | null { const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() if (!raw) return null if (raw.endsWith('px')) { const n = parseFloat(raw) return Number.isNaN(n) ? null : n } if (raw.endsWith('rem')) { const rem = parseFloat(raw) const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 return Number.isNaN(rem) ? null : rem * root } const n = parseFloat(raw) return Number.isNaN(n) ? null : n } /** سایدبار دسکتاپ معمولاً سمت راست (RTL) است. */ function readDesktopSidebarWidthPx(): number { if (document.documentElement.classList.contains('app-layout--desktop-no-sidebar')) { return 0 } return ( readCssLengthPx('--app-desktop-sidebar-width') ?? readCssLengthPx('--app-sidebar-width') ?? 260 ) } 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 getHorizontalBounds(): HorizontalBounds { const vw = window.innerWidth if (isDesktopLayout()) { const sidebar = readDesktopSidebarWidthPx() return { offsetX: 0, contentWidth: Math.max(width, vw - sidebar) } } const contentWidth = Math.min(vw, MOBILE_CONTENT_MAX) return { offsetX: Math.max(0, (vw - contentWidth) / 2), contentWidth } } function getDefaultPosition(): FabPosition { const vh = window.innerHeight const { offsetX, contentWidth } = getHorizontalBounds() const bottomChrome = isDesktopLayout() ? 0 : 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, contentWidth } = getHorizontalBounds() 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 } }