Skip to content

Commit 4a76011

Browse files
committed
feat: 增加侧边导航的展开按钮,会在移动端下访问,并且未开启工具栏时显示
1 parent 1680db6 commit 4a76011

25 files changed

Lines changed: 2050 additions & 8 deletions

File tree

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

apps/core-ant-design-vue/src/layouts/components/Topbar/Toolbar/startSide.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const appSettingsStore = useAppSettingsStore()
1111

1212
<template>
1313
<div class="flex items-center">
14-
<FaButton v-if="appSettingsStore.mode === 'mobile'" variant="ghost" size="icon" class="h-9 w-9 -rotate-z-180" @click="appSettingsStore.toggleSidebarCollapse()">
14+
<FaButton v-if="appSettingsStore.mode === 'mobile'" variant="ghost" size="icon" class="size-9" @click="appSettingsStore.toggleSidebarCollapse()">
1515
<FaIcon name="app-toolbar-collapse" class="size-4" />
1616
</FaButton>
1717
<Component :is="useSlots('toolbar-start')" />

apps/core-ant-design-vue/src/layouts/index.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useSlots } from '@/slots'
55
import { cn } from '@/utils'
66
import eventBus from '@/utils/eventBus'
77
import AppSetting from './components/AppSetting/index.vue'
8+
import FloatingSidebarMenuButton from './components/FloatingSidebarMenuButton/index.vue'
89
import Header from './components/Header/index.vue'
910
import Hotkeys from './components/Hotkeys/index.vue'
1011
import MainSidebar from './components/MainSidebar/index.vue'
@@ -158,6 +159,7 @@ const enableAppSetting = import.meta.env.VITE_APP_SETTING
158159
</div>
159160
<!-- 移动端下,展开侧边栏时的遮罩层 -->
160161
<div :class="cn('invisible fixed inset-0 z-1009 bg-black/50 op-0 backdrop-blur-sm transition-opacity', { 'op-100 visible': appSettingsStore.mode === 'mobile' && !appSettingsStore.settings.menu.subMenuCollapse })" @click="appSettingsStore.toggleSidebarCollapse()" />
162+
<FloatingSidebarMenuButton />
161163
<div class="main-container pb-[calc(var(--g-slots-layout-bottom-height)+var(--g-main-container-padding-bottom,0px))] pt-[calc(var(--g-slots-layout-top-height)+var(--g-header-actual-height)+var(--g-topbar-actual-height))]">
162164
<div
163165
class="fixed-content-around-area w-full inset-t-[calc(var(--g-slots-layout-top-height)+var(--g-header-actual-height))] inset-inline-1/2 fixed z-1005" :style="{

0 commit comments

Comments
 (0)