Files
ghatreh_user/src/utils/useDraggableFloatingButton.ts
T

292 lines
7.1 KiB
TypeScript
Raw Normal View History

2026-06-13 14:03:52 +03:30
import { computed, onMounted, onUnmounted, ref } from 'vue'
const DRAG_THRESHOLD = 6
/** v2: دسکتاپ از کنار سایدبار تا انتهای viewport (نه ستون ۴۲۶px) */
const POSITION_VERSION = 2
const MOBILE_CONTENT_MAX = 426
2026-06-13 14:03:52 +03:30
type FabPosition = { x: number; y: number }
2026-07-01 18:16:12 +03:30
type SavedFabOffset = {
version: number
dx: number
dy: number
}
type HorizontalBounds = {
offsetX: number
contentWidth: number
}
2026-06-13 14:03:52 +03:30
type DraggableFloatingOptions = {
width?: number
height?: number
2026-07-01 18:16:12 +03:30
defaultBottomGap?: number
2026-06-13 14:03:52 +03:30
defaultRightOffset?: number
}
2026-07-01 18:16:12 +03:30
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
)
}
2026-06-13 14:03:52 +03:30
export function useDraggableFloatingButton(
storageKey: string,
options: DraggableFloatingOptions = {}
) {
const width = options.width ?? 96
const height = options.height ?? 44
2026-07-01 18:16:12 +03:30
const defaultBottomGap = options.defaultBottomGap ?? 12
const defaultRightOffset = options.defaultRightOffset ?? 16
2026-06-13 14:03:52 +03:30
const pos = ref<FabPosition>({ 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 {
2026-06-13 14:03:52 +03:30
const vw = window.innerWidth
2026-07-01 18:16:12 +03:30
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
}
2026-06-13 14:03:52 +03:30
}
2026-07-01 18:16:12 +03:30
function getDefaultPosition(): FabPosition {
2026-06-13 14:03:52 +03:30
const vh = window.innerHeight
const { offsetX, contentWidth } = getHorizontalBounds()
const bottomChrome = isDesktopLayout() ? 0 : readBottomChromeHeight()
2026-06-13 14:03:52 +03:30
2026-07-01 18:16:12 +03:30
return {
2026-06-13 14:03:52 +03:30
x: offsetX + contentWidth - width - defaultRightOffset,
2026-07-01 18:16:12 +03:30
y: vh - bottomChrome - defaultBottomGap - height
2026-06-13 14:03:52 +03:30
}
}
2026-07-01 18:16:12 +03:30
function applySavedOffset(dx: number, dy: number) {
const base = getDefaultPosition()
pos.value = {
x: base.x + dx,
y: base.y + dy
}
clampToViewport()
}
2026-06-13 14:03:52 +03:30
function clampToViewport() {
const vh = window.innerHeight
const { offsetX, contentWidth } = getHorizontalBounds()
2026-07-01 18:16:12 +03:30
const minX = offsetX
const maxX = offsetX + contentWidth - width
2026-06-13 14:03:52 +03:30
pos.value = {
2026-07-01 18:16:12 +03:30
x: Math.max(minX, Math.min(maxX, pos.value.x)),
2026-06-13 14:03:52 +03:30
y: Math.max(0, Math.min(vh - height, pos.value.y))
}
}
function loadPosition() {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return false
2026-07-01 18:16:12 +03:30
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)
2026-06-13 14:03:52 +03:30
return true
}
} catch {
// ignore
}
2026-07-01 18:16:12 +03:30
2026-06-13 14:03:52 +03:30
return false
}
function savePosition() {
2026-07-01 18:16:12 +03:30
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()
2026-06-13 14:03:52 +03:30
}
function handleResize() {
2026-07-01 18:16:12 +03:30
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()
2026-06-13 14:03:52 +03:30
}
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
}
}