Skip to content

Commit 6fee00d

Browse files
committedDec 20, 2024
Native-like zoom instead of VDialog
1 parent 9146e43 commit 6fee00d

File tree

7 files changed

+169
-60
lines changed

7 files changed

+169
-60
lines changed
 

‎app/components/card-wrapper.vue

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<template lang="pug">
2+
div
3+
.card-page(ref='self' :class='wrapperClasses')
4+
slot
5+
.backdrop(@click.stop='open = false')
6+
</template>
7+
8+
<script setup lang="ts">
9+
import { computed, ref, watch } from 'vue'
10+
import { useMotionProperties, useMotionTransitions } from '@vueuse/motion'
11+
12+
function delay(seconds: number) {
13+
return new Promise(r => setTimeout(r, seconds * 1000))
14+
}
15+
16+
const props = defineProps<{ zoomLevel: number }>()
17+
18+
const self = ref<HTMLElement>(null!)
19+
const offsetTop = 64
20+
const footerHeight = 42
21+
const padding = 8
22+
23+
const open = defineModel<boolean>('open', { default: false })
24+
25+
const isFixed = ref(false)
26+
const isClosing = ref(false)
27+
28+
const wrapperClasses = computed(() => ({
29+
'open': isFixed.value,
30+
'closing': isClosing.value,
31+
}))
32+
33+
const { motionProperties: transform } = useMotionProperties(self, { x: 0, y: 0 })
34+
const { push, stop } = useMotionTransitions()
35+
const transition = { type: 'keyframes', ease: 'easeInOut', duration: 300 }
36+
let isAnimating = false
37+
38+
const { width: windowWidth, height: windowHeight } = useWindowSize()
39+
40+
function getOpenCoordinates() {
41+
const { width } = self.value.parentElement!.getBoundingClientRect()
42+
const cardOuterSize = (2 * padding + width) * props.zoomLevel
43+
return {
44+
x: (windowWidth.value - cardOuterSize) / 2,
45+
y: offsetTop + Math.max(8, (windowHeight.value - (offsetTop + footerHeight) - cardOuterSize) / 2),
46+
}
47+
}
48+
49+
watchDebounced(open, async (v) => {
50+
const { x, y } = self.value.parentElement!.getBoundingClientRect()
51+
const { x: openX, y: openY } = getOpenCoordinates()
52+
53+
await waitFor(() => !isAnimating, 50)
54+
isAnimating = true
55+
if (v) {
56+
isFixed.value = true
57+
push('x', openX, transform, { from: x, ...transition })
58+
push('y', openY, transform, { from: y, ...transition })
59+
push('scale', props.zoomLevel, transform, { from: 1, ...transition })
60+
push('padding', 8, transform, { from: 0, ...transition })
61+
await delay(.6)
62+
stop()
63+
} else {
64+
isClosing.value = true
65+
push('x', x, transform, { from: openX, ...transition })
66+
push('y', y, transform, { from: openY, ...transition })
67+
push('scale', 1, transform, { from: props.zoomLevel, ...transition })
68+
push('padding', 0, transform, { from: 1, ...transition })
69+
await delay(.6)
70+
stop()
71+
isClosing.value = false
72+
isFixed.value = false
73+
self.value.style.transform = ''
74+
}
75+
isAnimating = false
76+
}, { debounce: 50 })
77+
78+
watch(() => windowWidth.value + windowHeight.value, async () => {
79+
await waitFor(() => !isAnimating, 50)
80+
if (open.value) {
81+
const { x: openX, y: openY } = getOpenCoordinates()
82+
self.value.style.transform = `translate3d(${openX}px, ${openY}px, 0px) scale(${props.zoomLevel})`
83+
}
84+
})
85+
</script>
86+
87+
<style scoped>
88+
.card-page {
89+
position: relative;
90+
display: block;
91+
top: 0;
92+
left: 0;
93+
transform-origin: 0 0;
94+
95+
+ .backdrop {
96+
position: fixed;
97+
inset: 0;
98+
z-index: 9998;
99+
backdrop-filter: blur(4px);
100+
background-color: #0006;
101+
opacity: 0;
102+
pointer-events: none;
103+
transition: opacity 0.3s linear;
104+
}
105+
106+
&.open {
107+
position: fixed;
108+
max-height: calc(100dvh - 80px);
109+
z-index: 9999;
110+
111+
&:not(.closing) {
112+
overflow: auto;
113+
114+
+ .backdrop {
115+
pointer-events: all;
116+
opacity: 1;
117+
}
118+
}
119+
}
120+
}
121+
</style>

‎app/components/pass-card.vue

+26-21
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,35 @@
11
<template lang="pug">
2-
v-card.pa-6.pass-card(
3-
:width='cardSize * 30 - 4 + 48'
4-
v-bind='$attrs'
5-
)
6-
.card-characters
7-
v-chip(
8-
v-for='(ch, i) in characters'
9-
:key='i'
10-
:variant='cellVariants[isHighlighted(i) ? 1 : 0]'
11-
:color='ch.color'
12-
:ripple='!preview'
13-
@click='highlightRow(i)'
14-
@dblclick='highlightColumn(i)'
15-
) {{ preview ? '·' : ch.value }}
16-
// ◉ | ▣ | ⬢
17-
.card-number {{ footer }}
2+
card-wrapper(v-model:open='isOpen' :zoom-level='zoomLevel')
3+
v-card.pa-6.pass-card(
4+
:border='isOpen'
5+
:ripple='false'
6+
v-bind='!isOpen ? { onClick: toggleOpen } : {}'
7+
)
8+
.card-characters
9+
v-chip(
10+
v-for='(ch, i) in characters'
11+
:key='i'
12+
:variant='cellVariants[isHighlighted(i) ? 1 : 0]'
13+
:color='ch.color'
14+
:ripple='isOpen'
15+
v-bind='isOpen ? { onClick: () => highlightRow(i), onDblclick: () => highlightColumn(i) } : {}'
16+
) {{ !isOpen ? '·' : ch.value }}
17+
// ◉ | ▣ | ⬢
18+
.card-number {{ footer }}
1819
</template>
1920

2021
<script setup lang="ts">
2122
const props = defineProps<{
2223
chipVariant: 'text' | 'tonal' | 'flat' | 'outlined'
24+
zoomLevel: number
2325
footer: string
24-
preview?: boolean
2526
characters: TSign[]
2627
}>()
2728
29+
const isOpen = ref(false)
30+
const toggleOpen = () => isOpen.value = true
31+
defineExpose({ toggleOpen, isOpen })
32+
2833
const cardSize = 10
2934
3035
const highlightMap = (length: number) => Array.from({ length })
@@ -38,8 +43,8 @@ const highlightedCells = reactive(highlightMap(cardSize ** 2))
3843
const hasAnyHighlights = computed(() => Object.values(highlightedCells).some(Boolean))
3944
4045
const cellVariants = computed<any[]>(() => [
41-
hasAnyHighlights.value ? 'text' : props.chipVariant,
42-
props.chipVariant === 'text' ? 'tonal' : props.chipVariant,
46+
isOpen.value && hasAnyHighlights.value ? 'text' : props.chipVariant,
47+
isOpen.value && props.chipVariant === 'text' ? 'tonal' : props.chipVariant,
4348
])
4449
4550
function getCoordinates(cellIndex: number) {
@@ -50,14 +55,14 @@ function getCoordinates(cellIndex: number) {
5055
}
5156
5257
function highlightRow(cellIndex: number) {
53-
if (props.preview) return
58+
if (!isOpen.value) return
5459
const { row } = getCoordinates(cellIndex)
5560
highlightedRows[row] = !highlightedRows[row]
5661
props.characters.forEach((_, i) => highlightedCells[i] = isHighlighted(i))
5762
}
5863
5964
function highlightColumn(cellIndex: number) {
60-
if (props.preview) return
65+
if (!isOpen.value) return
6166
const { column } = getCoordinates(cellIndex)
6267
highlightedColumns[column] = !highlightedColumns[column]
6368
props.characters.forEach((_, i) => highlightedCells[i] = isHighlighted(i))

‎app/composables/words.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export function useWords() {
66
if (wordsArray.value.length > 0) return
77
cancelIdleCallback(idleCallbackId)
88
const response = await $fetch<string>('/words.txt', { headers: { 'content-type': 'plain/text' } })
9-
wordsArray.value = response.split('\n')
9+
wordsArray.value = response.split(/\r?\n/)
1010
}
1111

1212
onMounted(() => {

‎app/layouts/default.vue

+4-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ v-app.bg-surface-container
1313
target='_blank' href='https://jsek.work/'
1414
v-tooltip:bottom='"Open developer website"'
1515
)
16-
v-btn.ml-auto.bg-surface-dim.my-n3(
17-
rounded='lg' size='small'
16+
.ml-auto.mr-3.text-body-2.opacity-40 {{ version }}
17+
v-btn.my-n3(
18+
variant='text' rounded='lg' size='small' color='grey'
1819
:icon='mdiThemeLightDark'
1920
@click='toggleTheme'
2021
v-tooltip:bottom='"Toggle theme"'
@@ -25,4 +26,5 @@ v-app.bg-surface-container
2526
<script setup lang="ts">
2627
import { mdiCardAccountDetailsOutline, mdiThemeLightDark } from '@mdi/js'
2728
const { isDark, toggleTheme } = useAppTheme()
29+
const version = 'v1.0.0'
2830
</script>

‎app/pages/index.vue

+15-36
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template lang="pug">
2-
v-main
2+
v-main(scrollable)
33
.d-flex.align-center.justify-center.pt-6
44
.d-flex.align-center.position-relative
55
v-card.py-1.px-2.settings-card(variant='outlined' :class='{ "settings-card--visible": showSettings }')
@@ -36,34 +36,18 @@ v-main
3636
v-else-if='cards.length'
3737
:class='fontClass'
3838
)
39-
pass-card(
40-
v-for='card in cards'
41-
:key='card.index'
42-
:characters='card.characters'
43-
:chip-variant='chipVariant'
44-
:footer='`${card.index + 1} / ${cards.length}`'
45-
preview
46-
@click='selectedCard = card; showCard = true'
47-
ref='cardPreviews'
39+
.v-card-outter(
40+
v-for='card in cards' :key='card.index'
41+
:style='{ width: `${cardSize * 30 - 4 + 48}px` }'
4842
)
49-
50-
v-dialog(
51-
v-if='selectedCard'
52-
:model-value='selectedCardVisible'
53-
@update:model-value='showCard = false'
54-
:target='cardPreviews[selectedCard.index]'
55-
:min-width='cardSize * 30 - 4 + 48'
56-
:width='zoomLevel * (cardSize * 30 - 4 + 48)'
57-
:content-class='fontClass'
58-
scrim='#000'
59-
)
60-
pass-card(
61-
:characters='selectedCard.characters'
62-
:chip-variant='chipVariant'
63-
:style='{ zoom: zoomLevel }'
64-
:footer='`${selectedCard.index + 1} / ${cards.length}`'
65-
border
66-
)
43+
pass-card(
44+
:zoom-level='zoomLevel'
45+
:characters='card.characters'
46+
:chip-variant='chipVariant'
47+
:footer='`${card.index + 1} / ${cards.length}`'
48+
:ripple='false'
49+
ref='cardsRef'
50+
)
6751
</template>
6852

6953
<script setup lang="ts">
@@ -78,10 +62,7 @@ const pin = ref('')
7862
const pinLength = 4
7963
8064
const showSettings = ref(false)
81-
const selectedCard = ref<any | null>(null)
82-
const cardPreviews = ref([])
83-
const showCard = ref(false)
84-
const selectedCardVisible = refDebounced(showCard, 100)
65+
const cardsRef = ref<any[]>([])
8566
const zoomLevel = computed(() => {
8667
const viewportSize = Math.min(windowWidth.value, windowHeight.value - 64)
8768
return viewportSize > 740 ? 2 : Math.max(1, viewportSize / 370)
@@ -165,9 +146,8 @@ onMounted(async () => {
165146
username.value = 'yolo'
166147
pin.value = '2077'
167148
await nextTick()
168-
await waitFor(() => cards.value.length > 0, 50)
169-
selectedCard.value = cards.value.at(autofill.value)
170-
showCard.value = true
149+
await waitFor(() => cardsRef.value.length > 0, 50)
150+
cardsRef.value.at(autofill.value - 1).toggleOpen()
171151
}
172152
})
173153
</script>
@@ -186,7 +166,6 @@ onMounted(async () => {
186166
gap: 12px
187167
188168
padding: 12px
189-
max-width: 100vw
190169
overflow-x: auto
191170
192171
.settings-card

‎bun.lockb

3.5 KB
Binary file not shown.

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "pass-cards",
33
"private": true,
44
"type": "module",
5+
"version": "1.0.0",
56
"scripts": {
67
"build": "nuxt generate",
78
"dev": "nuxt dev --port 4000",
@@ -12,6 +13,7 @@
1213
"@pinia/nuxt": "0.7.0",
1314
"@vite-pwa/nuxt": "0.10.6",
1415
"@vueuse/core": "11.2.0",
16+
"@vueuse/motion": "^2.2.6",
1517
"@vueuse/nuxt": "11.2.0",
1618
"@vueuse/router": "^12.0.0",
1719
"nuxt": "^3.14.1592",

0 commit comments

Comments
 (0)
Please sign in to comment.