2026-06-13 14:03:52 +03:30
|
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
|
|
|
|
|
|
const DRAG_THRESHOLD = 6
|
2026-08-20 21:35:18 +03:30
|
|
|
/** 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:35:18 +03:30
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:35:18 +03:30
|
|
|
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'
|
|
|
|
|
}))
|
|
|
|
|
|
2026-08-20 21:35:18 +03:30
|
|
|
function getHorizontalBounds(): HorizontalBounds {
|
2026-06-13 14:03:52 +03:30
|
|
|
const vw = window.innerWidth
|
2026-07-01 18:16:12 +03:30
|
|
|
|
2026-08-20 21:35:18 +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
|
2026-08-20 21:35:18 +03:30
|
|
|
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
|
2026-08-20 21:35:18 +03:30
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|