|
| 1 | +import { hasFinePointer } from '@/utils/isMobile'; |
| 2 | +import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'; |
| 3 | + |
| 4 | +const PICKER_COLUMN_SELECTOR = '.nut-picker__column'; |
| 5 | +const WHEEL_STEP_THRESHOLD = 40; |
| 6 | +const WHEEL_GESTURE_IDLE_MS = 160; |
| 7 | +const WHEEL_MOVE_DURATION = 200; |
| 8 | + |
| 9 | +type PickerOption = { |
| 10 | + value: string | number; |
| 11 | + text?: string; |
| 12 | +}; |
| 13 | + |
| 14 | +type PickerColumnProxy = { |
| 15 | + $el?: Element | null; |
| 16 | + column?: PickerOption[]; |
| 17 | + currIndex?: number; |
| 18 | + lineSpacing?: number; |
| 19 | + transformY?: number; |
| 20 | + setMove?: (move: number, type?: string, time?: number) => void; |
| 21 | +}; |
| 22 | + |
| 23 | +type PickerProxy = { |
| 24 | + show?: boolean; |
| 25 | + pickerColumn?: PickerColumnProxy[]; |
| 26 | +}; |
| 27 | + |
| 28 | +type VisibleColumn = { |
| 29 | + element: HTMLElement; |
| 30 | + proxy: PickerColumnProxy; |
| 31 | +}; |
| 32 | + |
| 33 | +const isVisible = (element: HTMLElement) => { |
| 34 | + if (!element.isConnected) { |
| 35 | + return false; |
| 36 | + } |
| 37 | + |
| 38 | + const style = window.getComputedStyle(element); |
| 39 | + const rect = element.getBoundingClientRect(); |
| 40 | + |
| 41 | + return ( |
| 42 | + style.display !== 'none' && |
| 43 | + style.visibility !== 'hidden' && |
| 44 | + rect.width > 0 && |
| 45 | + rect.height > 0 |
| 46 | + ); |
| 47 | +}; |
| 48 | + |
| 49 | +const getVisibleColumns = (picker: PickerProxy | null | undefined) => { |
| 50 | + const columns = Array.isArray(picker?.pickerColumn) ? picker.pickerColumn : []; |
| 51 | + const elements = Array.from( |
| 52 | + document.querySelectorAll<HTMLElement>(PICKER_COLUMN_SELECTOR) |
| 53 | + ).filter(isVisible); |
| 54 | + |
| 55 | + return elements |
| 56 | + .map((element, index) => { |
| 57 | + const proxy = columns[index]; |
| 58 | + if (!proxy) { |
| 59 | + return null; |
| 60 | + } |
| 61 | + |
| 62 | + return { |
| 63 | + element, |
| 64 | + proxy, |
| 65 | + }; |
| 66 | + }) |
| 67 | + .filter((column): column is VisibleColumn => column !== null); |
| 68 | +}; |
| 69 | + |
| 70 | +const findColumnAtPoint = ( |
| 71 | + picker: PickerProxy | null | undefined, |
| 72 | + clientX: number, |
| 73 | + clientY: number |
| 74 | +) => { |
| 75 | + if ( |
| 76 | + !Number.isFinite(clientX) || |
| 77 | + !Number.isFinite(clientY) || |
| 78 | + (clientX === 0 && clientY === 0) |
| 79 | + ) { |
| 80 | + return null; |
| 81 | + } |
| 82 | + |
| 83 | + return getVisibleColumns(picker).find(column => { |
| 84 | + const rect = column.element.getBoundingClientRect(); |
| 85 | + return ( |
| 86 | + clientX >= rect.left && |
| 87 | + clientX <= rect.right && |
| 88 | + clientY >= rect.top && |
| 89 | + clientY <= rect.bottom |
| 90 | + ); |
| 91 | + })?.proxy ?? null; |
| 92 | +}; |
| 93 | + |
| 94 | +const movePickerSelection = (column: PickerColumnProxy, direction: 1 | -1) => { |
| 95 | + const optionCount = Array.isArray(column.column) ? column.column.length : 0; |
| 96 | + const currentIndex = Number(column.currIndex); |
| 97 | + const lineSpacing = Number(column.lineSpacing); |
| 98 | + const transformY = Number(column.transformY); |
| 99 | + |
| 100 | + if (!optionCount || !currentIndex || !lineSpacing || Number.isNaN(transformY)) { |
| 101 | + return false; |
| 102 | + } |
| 103 | + |
| 104 | + const nextIndex = Math.min(optionCount, Math.max(1, currentIndex + direction)); |
| 105 | + if (nextIndex === currentIndex || typeof column.setMove !== 'function') { |
| 106 | + return false; |
| 107 | + } |
| 108 | + |
| 109 | + const targetMove = -(nextIndex - 1) * lineSpacing; |
| 110 | + const relativeMove = targetMove - transformY; |
| 111 | + column.setMove(relativeMove, 'end', WHEEL_MOVE_DURATION); |
| 112 | + |
| 113 | + return true; |
| 114 | +}; |
| 115 | + |
| 116 | +export const useDesktopPickerWheel = (pickerRef: Ref<PickerProxy | null | undefined>) => { |
| 117 | + const wheelDeltaByColumn = new Map<PickerColumnProxy, number>(); |
| 118 | + const wheelResetTimerByColumn = new Map<PickerColumnProxy, ReturnType<typeof setTimeout>>(); |
| 119 | + |
| 120 | + const clearWheelResetTimer = (column: PickerColumnProxy) => { |
| 121 | + const timer = wheelResetTimerByColumn.get(column); |
| 122 | + if (timer) { |
| 123 | + clearTimeout(timer); |
| 124 | + wheelResetTimerByColumn.delete(column); |
| 125 | + } |
| 126 | + }; |
| 127 | + |
| 128 | + const resetWheelDelta = (column: PickerColumnProxy) => { |
| 129 | + clearWheelResetTimer(column); |
| 130 | + wheelDeltaByColumn.delete(column); |
| 131 | + }; |
| 132 | + |
| 133 | + const scheduleWheelDeltaReset = (column: PickerColumnProxy) => { |
| 134 | + clearWheelResetTimer(column); |
| 135 | + const timer = setTimeout(() => { |
| 136 | + wheelResetTimerByColumn.delete(column); |
| 137 | + wheelDeltaByColumn.delete(column); |
| 138 | + }, WHEEL_GESTURE_IDLE_MS); |
| 139 | + |
| 140 | + wheelResetTimerByColumn.set(column, timer); |
| 141 | + }; |
| 142 | + |
| 143 | + const resetAllWheelState = () => { |
| 144 | + wheelResetTimerByColumn.forEach(timer => { |
| 145 | + clearTimeout(timer); |
| 146 | + }); |
| 147 | + wheelResetTimerByColumn.clear(); |
| 148 | + wheelDeltaByColumn.clear(); |
| 149 | + }; |
| 150 | + |
| 151 | + const getColumnFromTarget = (event: WheelEvent | MouseEvent) => { |
| 152 | + const picker = pickerRef.value; |
| 153 | + const { target } = event; |
| 154 | + const visibleColumns = getVisibleColumns(picker); |
| 155 | + |
| 156 | + if (target instanceof Element) { |
| 157 | + const targetColumn = visibleColumns.find(column => { |
| 158 | + return column.element.contains(target); |
| 159 | + }); |
| 160 | + |
| 161 | + if (targetColumn) { |
| 162 | + return targetColumn.proxy; |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + const pointColumn = findColumnAtPoint(picker, event.clientX, event.clientY); |
| 167 | + if (pointColumn) { |
| 168 | + return pointColumn; |
| 169 | + } |
| 170 | + |
| 171 | + return null; |
| 172 | + }; |
| 173 | + |
| 174 | + const handleWheel = (event: WheelEvent) => { |
| 175 | + const picker = pickerRef.value; |
| 176 | + if (!picker?.show) { |
| 177 | + return; |
| 178 | + } |
| 179 | + |
| 180 | + const column = getColumnFromTarget(event); |
| 181 | + if (!column) { |
| 182 | + return; |
| 183 | + } |
| 184 | + |
| 185 | + const nextDelta = (wheelDeltaByColumn.get(column) ?? 0) + event.deltaY; |
| 186 | + wheelDeltaByColumn.set(column, nextDelta); |
| 187 | + scheduleWheelDeltaReset(column); |
| 188 | + |
| 189 | + event.preventDefault(); |
| 190 | + |
| 191 | + if (Math.abs(nextDelta) < WHEEL_STEP_THRESHOLD) { |
| 192 | + return; |
| 193 | + } |
| 194 | + |
| 195 | + resetWheelDelta(column); |
| 196 | + |
| 197 | + const direction = nextDelta > 0 ? 1 : -1; |
| 198 | + movePickerSelection(column, direction); |
| 199 | + }; |
| 200 | + |
| 201 | + onMounted(() => { |
| 202 | + if (!hasFinePointer() || typeof document === 'undefined') { |
| 203 | + return; |
| 204 | + } |
| 205 | + |
| 206 | + document.addEventListener('wheel', handleWheel, { |
| 207 | + capture: true, |
| 208 | + passive: false, |
| 209 | + }); |
| 210 | + }); |
| 211 | + |
| 212 | + onBeforeUnmount(() => { |
| 213 | + resetAllWheelState(); |
| 214 | + |
| 215 | + if (typeof document === 'undefined') { |
| 216 | + return; |
| 217 | + } |
| 218 | + |
| 219 | + document.removeEventListener('wheel', handleWheel, true); |
| 220 | + }); |
| 221 | + |
| 222 | + watch( |
| 223 | + () => pickerRef.value?.show, |
| 224 | + visible => { |
| 225 | + if (!visible) { |
| 226 | + resetAllWheelState(); |
| 227 | + } |
| 228 | + } |
| 229 | + ); |
| 230 | +}; |
0 commit comments