|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { CSSProperties } from 'vue' |
| 3 | +
|
| 4 | +type SnapSide = 'left' | 'right' |
| 5 | +
|
| 6 | +const EDGE_OFFSET = 4 |
| 7 | +const DRAG_THRESHOLD = 8 |
| 8 | +const DEFAULT_BUTTON_SIZE = 40 |
| 9 | +
|
| 10 | +const appSettingsStore = useAppSettingsStore() |
| 11 | +const buttonRef = useTemplateRef('buttonRef') |
| 12 | +
|
| 13 | +const buttonState = ref({ |
| 14 | + initialized: false, |
| 15 | + width: DEFAULT_BUTTON_SIZE, |
| 16 | + height: DEFAULT_BUTTON_SIZE, |
| 17 | + left: EDGE_OFFSET, |
| 18 | + top: EDGE_OFFSET, |
| 19 | + snapSide: 'left' as SnapSide, |
| 20 | +}) |
| 21 | +
|
| 22 | +const dragState = ref({ |
| 23 | + pointerId: -1, |
| 24 | + active: false, |
| 25 | + moved: false, |
| 26 | + suppressClick: false, |
| 27 | + startX: 0, |
| 28 | + startY: 0, |
| 29 | + startLeft: 0, |
| 30 | + startTop: 0, |
| 31 | +}) |
| 32 | +
|
| 33 | +const buttonStyle = computed<CSSProperties>(() => ({ |
| 34 | + left: buttonState.value.initialized |
| 35 | + ? `${buttonState.value.left}px` |
| 36 | + : `${EDGE_OFFSET}px`, |
| 37 | + right: buttonState.value.initialized |
| 38 | + ? 'auto' |
| 39 | + : 'auto', |
| 40 | + top: buttonState.value.initialized |
| 41 | + ? `${buttonState.value.top}px` |
| 42 | + : 'auto', |
| 43 | + bottom: buttonState.value.initialized |
| 44 | + ? 'auto' |
| 45 | + : `${EDGE_OFFSET}px`, |
| 46 | + transition: dragState.value.active ? 'none' : 'left 200ms ease, top 200ms ease', |
| 47 | + willChange: 'left, top', |
| 48 | +})) |
| 49 | +
|
| 50 | +function getButtonElement() { |
| 51 | + const el = buttonRef.value?.$el |
| 52 | + return el instanceof HTMLElement ? el : null |
| 53 | +} |
| 54 | +
|
| 55 | +function clamp(value: number, min: number, max: number) { |
| 56 | + return Math.min(Math.max(value, min), max) |
| 57 | +} |
| 58 | +
|
| 59 | +function getViewportBounds() { |
| 60 | + return { |
| 61 | + minLeft: EDGE_OFFSET, |
| 62 | + maxLeft: Math.max(EDGE_OFFSET, window.innerWidth - buttonState.value.width - EDGE_OFFSET), |
| 63 | + minTop: EDGE_OFFSET, |
| 64 | + maxTop: Math.max(EDGE_OFFSET, window.innerHeight - buttonState.value.height - EDGE_OFFSET), |
| 65 | + } |
| 66 | +} |
| 67 | +
|
| 68 | +function syncButtonMetrics() { |
| 69 | + const rect = getButtonElement()?.getBoundingClientRect() |
| 70 | + if (!rect) { |
| 71 | + return |
| 72 | + } |
| 73 | + buttonState.value.width = rect.width || DEFAULT_BUTTON_SIZE |
| 74 | + buttonState.value.height = rect.height || DEFAULT_BUTTON_SIZE |
| 75 | +} |
| 76 | +
|
| 77 | +function setButtonPosition(left = buttonState.value.left, top = buttonState.value.top) { |
| 78 | + const bounds = getViewportBounds() |
| 79 | + buttonState.value.left = clamp(left, bounds.minLeft, bounds.maxLeft) |
| 80 | + buttonState.value.top = clamp(top, bounds.minTop, bounds.maxTop) |
| 81 | +} |
| 82 | +
|
| 83 | +function snapToSide(side: SnapSide) { |
| 84 | + const bounds = getViewportBounds() |
| 85 | + buttonState.value.snapSide = side |
| 86 | + buttonState.value.left = side === 'left' ? bounds.minLeft : bounds.maxLeft |
| 87 | + buttonState.value.top = clamp(buttonState.value.top, bounds.minTop, bounds.maxTop) |
| 88 | +} |
| 89 | +
|
| 90 | +function syncPositionToViewport() { |
| 91 | + syncButtonMetrics() |
| 92 | + const bounds = getViewportBounds() |
| 93 | + buttonState.value.top = clamp(buttonState.value.top, bounds.minTop, bounds.maxTop) |
| 94 | + buttonState.value.left = buttonState.value.snapSide === 'right' |
| 95 | + ? bounds.maxLeft |
| 96 | + : clamp(buttonState.value.left, bounds.minLeft, bounds.maxLeft) |
| 97 | +} |
| 98 | +
|
| 99 | +async function initializeButtonPosition() { |
| 100 | + if (appSettingsStore.mode !== 'mobile') { |
| 101 | + return |
| 102 | + } |
| 103 | +
|
| 104 | + await nextTick() |
| 105 | + syncButtonMetrics() |
| 106 | + if (!getButtonElement()) { |
| 107 | + return |
| 108 | + } |
| 109 | +
|
| 110 | + if (!buttonState.value.initialized) { |
| 111 | + const bounds = getViewportBounds() |
| 112 | + buttonState.value.snapSide = 'left' |
| 113 | + buttonState.value.left = bounds.minLeft |
| 114 | + buttonState.value.top = bounds.maxTop |
| 115 | + buttonState.value.initialized = true |
| 116 | + } |
| 117 | +
|
| 118 | + setButtonPosition() |
| 119 | +} |
| 120 | +
|
| 121 | +function handlePointerDown(event: PointerEvent) { |
| 122 | + if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) { |
| 123 | + return |
| 124 | + } |
| 125 | +
|
| 126 | + const target = event.currentTarget |
| 127 | + if (!(target instanceof HTMLElement)) { |
| 128 | + return |
| 129 | + } |
| 130 | +
|
| 131 | + syncButtonMetrics() |
| 132 | + dragState.value.pointerId = event.pointerId |
| 133 | + dragState.value.active = true |
| 134 | + dragState.value.moved = false |
| 135 | + dragState.value.startX = event.clientX |
| 136 | + dragState.value.startY = event.clientY |
| 137 | + dragState.value.startLeft = buttonState.value.left |
| 138 | + dragState.value.startTop = buttonState.value.top |
| 139 | + target.setPointerCapture?.(event.pointerId) |
| 140 | +} |
| 141 | +
|
| 142 | +function handlePointerMove(event: PointerEvent) { |
| 143 | + if (!dragState.value.active || dragState.value.pointerId !== event.pointerId) { |
| 144 | + return |
| 145 | + } |
| 146 | +
|
| 147 | + const deltaX = event.clientX - dragState.value.startX |
| 148 | + const deltaY = event.clientY - dragState.value.startY |
| 149 | +
|
| 150 | + if (!dragState.value.moved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) { |
| 151 | + return |
| 152 | + } |
| 153 | +
|
| 154 | + dragState.value.moved = true |
| 155 | + setButtonPosition(dragState.value.startLeft + deltaX, dragState.value.startTop + deltaY) |
| 156 | +} |
| 157 | +
|
| 158 | +function finishDrag(target?: EventTarget | null) { |
| 159 | + const pointerId = dragState.value.pointerId |
| 160 | + const shouldSuppressClick = dragState.value.moved |
| 161 | +
|
| 162 | + dragState.value.pointerId = -1 |
| 163 | + dragState.value.active = false |
| 164 | + dragState.value.moved = false |
| 165 | +
|
| 166 | + if (target instanceof HTMLElement && pointerId >= 0 && target.hasPointerCapture?.(pointerId)) { |
| 167 | + target.releasePointerCapture(pointerId) |
| 168 | + } |
| 169 | +
|
| 170 | + if (shouldSuppressClick) { |
| 171 | + const nextSide: SnapSide = buttonState.value.left + buttonState.value.width / 2 <= window.innerWidth / 2 ? 'left' : 'right' |
| 172 | + snapToSide(nextSide) |
| 173 | + } |
| 174 | +
|
| 175 | + dragState.value.suppressClick = shouldSuppressClick |
| 176 | +} |
| 177 | +
|
| 178 | +function handlePointerUp(event: PointerEvent) { |
| 179 | + if (dragState.value.pointerId !== event.pointerId) { |
| 180 | + return |
| 181 | + } |
| 182 | + finishDrag(event.currentTarget) |
| 183 | +} |
| 184 | +
|
| 185 | +function handlePointerCancel(event: PointerEvent) { |
| 186 | + if (dragState.value.pointerId !== event.pointerId) { |
| 187 | + return |
| 188 | + } |
| 189 | + finishDrag(event.currentTarget) |
| 190 | +} |
| 191 | +
|
| 192 | +function handleLostPointerCapture(event: PointerEvent) { |
| 193 | + if (dragState.value.active && dragState.value.pointerId === event.pointerId) { |
| 194 | + finishDrag(event.currentTarget) |
| 195 | + } |
| 196 | +} |
| 197 | +
|
| 198 | +function handleClick() { |
| 199 | + if (dragState.value.suppressClick) { |
| 200 | + dragState.value.suppressClick = false |
| 201 | + return |
| 202 | + } |
| 203 | + appSettingsStore.toggleSidebarCollapse() |
| 204 | +} |
| 205 | +
|
| 206 | +function handleViewportResize() { |
| 207 | + if (appSettingsStore.mode !== 'mobile' || !buttonState.value.initialized) { |
| 208 | + return |
| 209 | + } |
| 210 | + syncPositionToViewport() |
| 211 | +} |
| 212 | +
|
| 213 | +watch(() => appSettingsStore.mode, async (mode) => { |
| 214 | + if (mode === 'mobile') { |
| 215 | + await initializeButtonPosition() |
| 216 | + return |
| 217 | + } |
| 218 | +
|
| 219 | + dragState.value.pointerId = -1 |
| 220 | + dragState.value.active = false |
| 221 | + dragState.value.moved = false |
| 222 | +}, { |
| 223 | + immediate: true, |
| 224 | +}) |
| 225 | +
|
| 226 | +onMounted(() => { |
| 227 | + window.addEventListener('resize', handleViewportResize, { passive: true }) |
| 228 | +}) |
| 229 | +
|
| 230 | +onUnmounted(() => { |
| 231 | + window.removeEventListener('resize', handleViewportResize) |
| 232 | +}) |
| 233 | +</script> |
| 234 | + |
| 235 | +<template> |
| 236 | + <FaButton |
| 237 | + v-if="appSettingsStore.mode === 'mobile' && !appSettingsStore.settings.topbar.toolbar" |
| 238 | + ref="buttonRef" |
| 239 | + variant="outline" |
| 240 | + size="icon" |
| 241 | + class="rounded-full size-10 select-none shadow-sm fixed z-1008 touch-none" |
| 242 | + :class="{ 'cursor-grabbing': dragState.active, 'cursor-grab': !dragState.active }" |
| 243 | + :style="buttonStyle" |
| 244 | + @pointerdown="handlePointerDown" |
| 245 | + @pointermove="handlePointerMove" |
| 246 | + @pointerup="handlePointerUp" |
| 247 | + @pointercancel="handlePointerCancel" |
| 248 | + @lostpointercapture="handleLostPointerCapture" |
| 249 | + @click="handleClick" |
| 250 | + > |
| 251 | + <FaIcon name="app-toolbar-collapse" class="size-4 rotate-180" /> |
| 252 | + </FaButton> |
| 253 | +</template> |
0 commit comments