Skip to content

Commit e30534a

Browse files
committed
feat: 优化桌面端选择器交互
1 parent 4abc9ac commit e30534a

10 files changed

Lines changed: 273 additions & 99 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store-front-end",
3-
"version": "2.16.50",
3+
"version": "2.16.52",
44
"private": true,
55
"scripts": {
66
"dev": "vite --host",

src/components/DesktopPicker.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<template>
2+
<nut-picker ref="pickerRef">
3+
<template v-for="(_, name) in slots" #[name]="slotProps">
4+
<slot :name="name" v-bind="slotProps || {}" />
5+
</template>
6+
</nut-picker>
7+
</template>
8+
9+
<script setup lang="ts">
10+
import { ref, useSlots } from 'vue';
11+
import { useDesktopPickerWheel } from '@/hooks/useDesktopPickerWheel';
12+
13+
const slots = useSlots();
14+
const pickerRef = ref();
15+
16+
useDesktopPickerWheel(pickerRef);
17+
</script>

src/hooks/useDesktopPickerWheel.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
};

src/hooks/useMousePicker.ts

Lines changed: 0 additions & 78 deletions
This file was deleted.

src/utils/isMobile.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
export const hasFinePointer = () => {
2+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
3+
return false;
4+
}
5+
6+
return window.matchMedia('(any-pointer: fine)').matches;
7+
};
8+
19
export const isMobile = () => {
210
try {
311
document.createEvent('TouchEvent');

src/views/FileEditor.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@
405405
<icon-popup v-if="iconPopupVisible" v-model:visible="iconPopupVisible" @setIcon="setIcon">
406406
</icon-popup>
407407
<!-- 订阅名称 -->
408-
<nut-picker
408+
<DesktopPicker
409409
:key="sourceNameColumns.length"
410410
v-model="selectSourceName"
411411
v-model:visible="showSourceNamePicker"
@@ -418,7 +418,7 @@
418418
<div v-if="!sourceNameColumns.length" class="empty-tips" @click="goAddSub">
419419
<p>{{ t(`editorPage.subConfig.sourceNamePicker.emptyTips`) }}</p>
420420
</div>
421-
</nut-picker>
421+
</DesktopPicker>
422422
<tag-popup
423423
v-model:visible="tagPopupVisible"
424424
ref="tagPopupRef"
@@ -446,6 +446,7 @@ import Script from "@/views/editor/components/Script.vue";
446446
import TagPopup from "@/components/TagPopup.vue";
447447
import IconPopup from "@/views/icon/IconPopup.vue";
448448
import FilePreview from "@/views/FilePreview.vue";
449+
import DesktopPicker from "@/components/DesktopPicker.vue";
449450
import { initStores } from "@/utils/initApp";
450451
import { Dialog, Toast } from "@nutui/nutui";
451452
import { storeToRefs } from "pinia";

src/views/editor/ActionBlock.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,6 @@
198198
</template>
199199

200200
<script lang="ts" setup>
201-
// import { useMousePicker } from '@/hooks/useMousePicker';
202201
import i18nFile from '@/locales/zh';
203202
import { Dialog, Toast } from '@nutui/nutui';
204203
import { ref, inject, reactive, watch, nextTick, computed } from 'vue';
@@ -241,8 +240,6 @@ if (sourceType === 'file') {
241240
items = items.filter(item => ['Script Operator'].includes(item.value));
242241
}
243242
const columns = ref(items);
244-
// useMousePicker();
245-
246243
if(isCollapsed.value) {
247244
collapsedElements.value = list.map((item) => item.id);
248245
} else {

0 commit comments

Comments
 (0)