frappe-ui 0.1.127 → 0.1.129
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/Combobox/Combobox.vue +299 -0
- package/src/components/Combobox/index.ts +1 -0
- package/src/components/TextEditor/EditLink.vue +74 -0
- package/src/components/TextEditor/EmojiList.vue +39 -100
- package/src/components/TextEditor/ImageViewerModal.vue +63 -5
- package/src/components/TextEditor/SlashCommandsList.vue +47 -0
- package/src/components/TextEditor/SuggestionList.vue +134 -0
- package/src/components/TextEditor/TextEditor.vue +11 -8
- package/src/components/TextEditor/image-extension.ts +25 -3
- package/src/components/TextEditor/link-extension.ts +162 -0
- package/src/components/TextEditor/slash-commands-extension.ts +265 -0
- package/src/components/TextEditor/video-extension.ts +166 -0
- package/src/{index.js → index.ts} +7 -6
- package/src/components/TextEditor/video-extension.js +0 -61
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.129",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
|
-
"main": "./src/index.
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "vitest",
|
|
8
8
|
"prettier": "yarn prettier -w ./src",
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
type Component,
|
|
5
|
+
ref,
|
|
6
|
+
watch,
|
|
7
|
+
h,
|
|
8
|
+
FunctionalComponent,
|
|
9
|
+
} from 'vue'
|
|
10
|
+
import {
|
|
11
|
+
ComboboxAnchor,
|
|
12
|
+
ComboboxContent,
|
|
13
|
+
ComboboxEmpty,
|
|
14
|
+
ComboboxGroup,
|
|
15
|
+
ComboboxInput,
|
|
16
|
+
ComboboxItem,
|
|
17
|
+
ComboboxItemIndicator,
|
|
18
|
+
ComboboxLabel,
|
|
19
|
+
ComboboxPortal,
|
|
20
|
+
ComboboxRoot,
|
|
21
|
+
ComboboxSeparator,
|
|
22
|
+
ComboboxTrigger,
|
|
23
|
+
ComboboxViewport,
|
|
24
|
+
} from 'reka-ui'
|
|
25
|
+
import LucideCheck from '~icons/lucide/check'
|
|
26
|
+
import LucideChevronDown from '~icons/lucide/chevron-down'
|
|
27
|
+
|
|
28
|
+
type SimpleOption =
|
|
29
|
+
| string
|
|
30
|
+
| {
|
|
31
|
+
label: string
|
|
32
|
+
value: string
|
|
33
|
+
icon?: string | Component
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
}
|
|
36
|
+
type GroupedOption = { group: string; options: SimpleOption[] }
|
|
37
|
+
type ComboboxOption = SimpleOption | GroupedOption
|
|
38
|
+
|
|
39
|
+
interface ComboboxProps {
|
|
40
|
+
options: Array<ComboboxOption>
|
|
41
|
+
modelValue?: string | null
|
|
42
|
+
placeholder?: string
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = defineProps<ComboboxProps>()
|
|
47
|
+
const emit = defineEmits(['update:modelValue', 'update:selectedOption'])
|
|
48
|
+
|
|
49
|
+
const searchTerm = ref('')
|
|
50
|
+
const internalModelValue = ref(props.modelValue)
|
|
51
|
+
const isOpen = ref(false)
|
|
52
|
+
const userHasTyped = ref(false)
|
|
53
|
+
|
|
54
|
+
watch(
|
|
55
|
+
() => props.modelValue,
|
|
56
|
+
(newValue) => {
|
|
57
|
+
internalModelValue.value = newValue
|
|
58
|
+
searchTerm.value = getDisplayValue(newValue)
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const onUpdateModelValue = (value: string | null) => {
|
|
63
|
+
internalModelValue.value = value
|
|
64
|
+
emit('update:modelValue', value)
|
|
65
|
+
searchTerm.value = getDisplayValue(value)
|
|
66
|
+
userHasTyped.value = false
|
|
67
|
+
|
|
68
|
+
const selectedOpt = value
|
|
69
|
+
? allOptionsFlat.value.find((opt) => getValue(opt) === value) || null
|
|
70
|
+
: null
|
|
71
|
+
emit('update:selectedOption', selectedOpt)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isGroup = (option: ComboboxOption): option is GroupedOption => {
|
|
75
|
+
return typeof option === 'object' && 'group' in option
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const getLabel = (option: SimpleOption): string => {
|
|
79
|
+
return typeof option === 'string' ? option : option.label
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const getValue = (option: SimpleOption): string => {
|
|
83
|
+
return typeof option === 'string' ? option : option.value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isDisabled = (option: SimpleOption): boolean => {
|
|
87
|
+
return typeof option === 'object' && !!option.disabled
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getIcon = (option: SimpleOption): string | Component | undefined => {
|
|
91
|
+
return typeof option === 'object' ? option.icon : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const allOptionsFlat = computed(() => {
|
|
95
|
+
const flatOptions: SimpleOption[] = []
|
|
96
|
+
props.options.forEach((optionOrGroup) => {
|
|
97
|
+
if (isGroup(optionOrGroup)) {
|
|
98
|
+
flatOptions.push(...optionOrGroup.options)
|
|
99
|
+
} else {
|
|
100
|
+
flatOptions.push(optionOrGroup)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
return flatOptions
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
function getDisplayValue(selectedValue: string | null | undefined): string {
|
|
107
|
+
if (!selectedValue) return ''
|
|
108
|
+
const selectedOption = allOptionsFlat.value.find(
|
|
109
|
+
(opt) => getValue(opt) === selectedValue,
|
|
110
|
+
)
|
|
111
|
+
return selectedOption ? getLabel(selectedOption) : selectedValue || ''
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const selectedOption = computed(() => {
|
|
115
|
+
if (!internalModelValue.value) return null
|
|
116
|
+
return allOptionsFlat.value.find(
|
|
117
|
+
(opt) => getValue(opt) === internalModelValue.value,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const selectedOptionIcon = computed(() => {
|
|
122
|
+
return selectedOption.value ? getIcon(selectedOption.value) : undefined
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const RenderIcon: FunctionalComponent<{ icon?: string | Component }> = (
|
|
126
|
+
props,
|
|
127
|
+
) => {
|
|
128
|
+
if (!props.icon) return null
|
|
129
|
+
const iconContent =
|
|
130
|
+
typeof props.icon === 'string'
|
|
131
|
+
? h('span', props.icon)
|
|
132
|
+
: h(props.icon, { class: 'w-4 h-4' })
|
|
133
|
+
|
|
134
|
+
return h(
|
|
135
|
+
'span',
|
|
136
|
+
{
|
|
137
|
+
class: 'flex-shrink-0 w-4 h-4 inline-flex items-center justify-center',
|
|
138
|
+
},
|
|
139
|
+
[iconContent],
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const filterFunction = (options: ComboboxOption[], search: string) => {
|
|
144
|
+
if (!search) return options
|
|
145
|
+
|
|
146
|
+
const lowerSearch = search.toLowerCase()
|
|
147
|
+
const filtered: ComboboxOption[] = []
|
|
148
|
+
|
|
149
|
+
options.forEach((optionOrGroup) => {
|
|
150
|
+
if (isGroup(optionOrGroup)) {
|
|
151
|
+
const filteredGroupOptions = optionOrGroup.options.filter((opt) => {
|
|
152
|
+
const label = getLabel(opt).toLowerCase()
|
|
153
|
+
const value = getValue(opt).toLowerCase()
|
|
154
|
+
|
|
155
|
+
return label.includes(lowerSearch) || value.includes(lowerSearch)
|
|
156
|
+
})
|
|
157
|
+
if (filteredGroupOptions.length > 0) {
|
|
158
|
+
filtered.push({ ...optionOrGroup, options: filteredGroupOptions })
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const label = getLabel(optionOrGroup).toLowerCase()
|
|
162
|
+
const value = getValue(optionOrGroup).toLowerCase()
|
|
163
|
+
if (label.includes(lowerSearch) || value.includes(lowerSearch)) {
|
|
164
|
+
filtered.push(optionOrGroup)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
return filtered
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const filteredOptions = computed(() => {
|
|
172
|
+
if (isOpen.value && !userHasTyped.value && internalModelValue.value) {
|
|
173
|
+
return props.options
|
|
174
|
+
}
|
|
175
|
+
return filterFunction(props.options, searchTerm.value)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const handleInputChange = (event: Event) => {
|
|
179
|
+
const target = event.target as HTMLInputElement
|
|
180
|
+
searchTerm.value = target.value
|
|
181
|
+
userHasTyped.value = true
|
|
182
|
+
|
|
183
|
+
if (searchTerm.value === '') {
|
|
184
|
+
internalModelValue.value = null
|
|
185
|
+
emit('update:modelValue', null)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const handleOpenChange = (open: boolean) => {
|
|
190
|
+
isOpen.value = open
|
|
191
|
+
if (!open) {
|
|
192
|
+
searchTerm.value = getDisplayValue(internalModelValue.value)
|
|
193
|
+
userHasTyped.value = false
|
|
194
|
+
} else {
|
|
195
|
+
userHasTyped.value = false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<template>
|
|
201
|
+
<ComboboxRoot
|
|
202
|
+
:model-value="internalModelValue"
|
|
203
|
+
@update:modelValue="onUpdateModelValue"
|
|
204
|
+
@update:open="handleOpenChange"
|
|
205
|
+
class="relative"
|
|
206
|
+
:ignore-filter="true"
|
|
207
|
+
>
|
|
208
|
+
<ComboboxAnchor
|
|
209
|
+
class="flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus-within:border-outline-gray-4 focus-within:ring-2 focus-within:ring-outline-gray-3"
|
|
210
|
+
:class="{ 'opacity-50 pointer-events-none': disabled }"
|
|
211
|
+
>
|
|
212
|
+
<div class="flex items-center gap-2 flex-1 overflow-hidden">
|
|
213
|
+
<RenderIcon v-if="selectedOptionIcon" :icon="selectedOptionIcon" />
|
|
214
|
+
<ComboboxInput
|
|
215
|
+
:value="searchTerm"
|
|
216
|
+
@input="handleInputChange"
|
|
217
|
+
class="bg-transparent p-0 focus:outline-0 border-0 focus:border-0 focus:ring-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full"
|
|
218
|
+
:placeholder="placeholder || ''"
|
|
219
|
+
:disabled="disabled"
|
|
220
|
+
autocomplete="off"
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
<ComboboxTrigger :disabled="disabled">
|
|
224
|
+
<LucideChevronDown class="h-4 w-4 text-ink-gray-5" />
|
|
225
|
+
</ComboboxTrigger>
|
|
226
|
+
</ComboboxAnchor>
|
|
227
|
+
<ComboboxPortal>
|
|
228
|
+
<ComboboxContent
|
|
229
|
+
class="z-10 min-w-[--reka-combobox-trigger-width] mt-1 bg-surface-modal overflow-hidden rounded-lg shadow-2xl"
|
|
230
|
+
position="popper"
|
|
231
|
+
@openAutoFocus.prevent
|
|
232
|
+
@closeAutoFocus.prevent
|
|
233
|
+
:align="'start'"
|
|
234
|
+
>
|
|
235
|
+
<ComboboxViewport
|
|
236
|
+
class="max-h-60 overflow-auto pb-1.5"
|
|
237
|
+
:class="{ 'px-1.5 pt-1.5': !isGroup(filteredOptions[0]) }"
|
|
238
|
+
>
|
|
239
|
+
<ComboboxEmpty
|
|
240
|
+
class="text-ink-gray-5 text-base text-center py-1.5 px-2.5"
|
|
241
|
+
>
|
|
242
|
+
No results found for "{{ searchTerm }}"
|
|
243
|
+
</ComboboxEmpty>
|
|
244
|
+
|
|
245
|
+
<template
|
|
246
|
+
v-for="(optionOrGroup, index) in filteredOptions"
|
|
247
|
+
:key="index"
|
|
248
|
+
>
|
|
249
|
+
<ComboboxGroup class="px-1.5" v-if="isGroup(optionOrGroup)">
|
|
250
|
+
<ComboboxLabel
|
|
251
|
+
class="px-2.5 pt-3 pb-1.5 text-sm font-medium text-ink-gray-5 sticky top-0 bg-surface-modal z-10"
|
|
252
|
+
>
|
|
253
|
+
{{ optionOrGroup.group }}
|
|
254
|
+
</ComboboxLabel>
|
|
255
|
+
<ComboboxItem
|
|
256
|
+
v-for="(option, idx) in optionOrGroup.options"
|
|
257
|
+
:key="`${index}-${idx}`"
|
|
258
|
+
:value="getValue(option)"
|
|
259
|
+
:disabled="isDisabled(option)"
|
|
260
|
+
class="text-base leading-none text-ink-gray-7 rounded flex items-center h-7 px-2.5 py-1.5 relative select-none data-[disabled]:opacity-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3"
|
|
261
|
+
>
|
|
262
|
+
<span class="flex items-center gap-2 pr-6 flex-1">
|
|
263
|
+
<RenderIcon :icon="getIcon(option)" />
|
|
264
|
+
{{ getLabel(option) }}
|
|
265
|
+
</span>
|
|
266
|
+
<ComboboxItemIndicator
|
|
267
|
+
class="inline-flex ml-2 items-center justify-center"
|
|
268
|
+
>
|
|
269
|
+
<LucideCheck class="size-4" />
|
|
270
|
+
</ComboboxItemIndicator>
|
|
271
|
+
</ComboboxItem>
|
|
272
|
+
</ComboboxGroup>
|
|
273
|
+
|
|
274
|
+
<ComboboxItem
|
|
275
|
+
v-else
|
|
276
|
+
:key="index"
|
|
277
|
+
:value="getValue(optionOrGroup)"
|
|
278
|
+
:disabled="isDisabled(optionOrGroup)"
|
|
279
|
+
class="text-base leading-none text-ink-gray-7 rounded flex items-center h-7 px-2.5 py-1.5 relative select-none data-[disabled]:opacity-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3"
|
|
280
|
+
>
|
|
281
|
+
<span class="flex items-center gap-2 pr-6 flex-1">
|
|
282
|
+
<RenderIcon
|
|
283
|
+
v-if="getIcon(optionOrGroup)"
|
|
284
|
+
:icon="getIcon(optionOrGroup)"
|
|
285
|
+
/>
|
|
286
|
+
{{ getLabel(optionOrGroup) }}
|
|
287
|
+
</span>
|
|
288
|
+
<ComboboxItemIndicator
|
|
289
|
+
class="absolute right-0 w-6 inline-flex items-center justify-center"
|
|
290
|
+
>
|
|
291
|
+
<LucideCheck class="h-4 w-4" />
|
|
292
|
+
</ComboboxItemIndicator>
|
|
293
|
+
</ComboboxItem>
|
|
294
|
+
</template>
|
|
295
|
+
</ComboboxViewport>
|
|
296
|
+
</ComboboxContent>
|
|
297
|
+
</ComboboxPortal>
|
|
298
|
+
</ComboboxRoot>
|
|
299
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Combobox } from './Combobox.vue'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="p-2 flex min-w-72 items-center gap-2 bg-surface-white shadow-xl rounded-lg"
|
|
4
|
+
>
|
|
5
|
+
<TextInput
|
|
6
|
+
ref="input"
|
|
7
|
+
type="text"
|
|
8
|
+
class="w-full"
|
|
9
|
+
placeholder="https://example.com"
|
|
10
|
+
v-model="_href"
|
|
11
|
+
@keydown.enter="submitLink"
|
|
12
|
+
@keydown.esc="$emit('close')"
|
|
13
|
+
/>
|
|
14
|
+
<div class="shrink-0 flex items-center gap-2">
|
|
15
|
+
<Tooltip text="Submit" placement="top">
|
|
16
|
+
<Button label="Submit" @click="submitLink">
|
|
17
|
+
<template #icon><LucideCheck class="size-4" /></template>
|
|
18
|
+
</Button>
|
|
19
|
+
</Tooltip>
|
|
20
|
+
<Tooltip text="Remove link" placement="top">
|
|
21
|
+
<Button label="Remove link" @click="$emit('updateHref', '')">
|
|
22
|
+
<template #icon><LucideX class="size-4" /></template>
|
|
23
|
+
</Button>
|
|
24
|
+
</Tooltip>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { onMounted, ref, useTemplateRef, defineEmits } from 'vue'
|
|
31
|
+
import Button from '../Button/Button.vue'
|
|
32
|
+
import TextInput from '../TextInput.vue'
|
|
33
|
+
import Tooltip from '../Tooltip/Tooltip.vue'
|
|
34
|
+
|
|
35
|
+
const props = defineProps<{
|
|
36
|
+
show: boolean
|
|
37
|
+
href: string
|
|
38
|
+
onClose: () => void
|
|
39
|
+
onUpdateHref: (href: string) => void
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits<{
|
|
43
|
+
(e: 'updateHref', href: string): void
|
|
44
|
+
(e: 'close'): void
|
|
45
|
+
}>()
|
|
46
|
+
|
|
47
|
+
const _href = ref(props.href)
|
|
48
|
+
const input = useTemplateRef('input')
|
|
49
|
+
|
|
50
|
+
// Simple URL validation regex
|
|
51
|
+
const isValidUrl = (url: string) => {
|
|
52
|
+
if (!url) return true
|
|
53
|
+
const regex =
|
|
54
|
+
/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i
|
|
55
|
+
return regex.test(url)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const submitLink = () => {
|
|
59
|
+
if (isValidUrl(_href.value)) {
|
|
60
|
+
emit('updateHref', _href.value)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onMounted(() => {
|
|
65
|
+
if (props.show) {
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
if (input.value?.el) {
|
|
68
|
+
input.value.el.focus()
|
|
69
|
+
input.value.el.select()
|
|
70
|
+
}
|
|
71
|
+
}, 0)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
</script>
|
|
@@ -1,111 +1,50 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
]"
|
|
14
|
-
@click="selectItem(index)"
|
|
15
|
-
@mouseover="selectedIndex = index"
|
|
16
|
-
>
|
|
17
|
-
<span class="mr-2">{{ item.emoji }}</span>
|
|
18
|
-
<span>{{ item.name }}</span>
|
|
19
|
-
</button>
|
|
20
|
-
</div>
|
|
21
|
-
</div>
|
|
2
|
+
<SuggestionList
|
|
3
|
+
ref="suggestionList"
|
|
4
|
+
:items="items"
|
|
5
|
+
:command="selectItem"
|
|
6
|
+
item-class="py-2"
|
|
7
|
+
>
|
|
8
|
+
<template #default="{ item }">
|
|
9
|
+
<span class="mr-2">{{ item.emoji }}</span>
|
|
10
|
+
<span>{{ item.name }}</span>
|
|
11
|
+
</template>
|
|
12
|
+
</SuggestionList>
|
|
22
13
|
</template>
|
|
23
14
|
|
|
24
|
-
<script>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
items: {
|
|
28
|
-
type: Array,
|
|
29
|
-
required: true,
|
|
30
|
-
},
|
|
31
|
-
command: {
|
|
32
|
-
type: Function,
|
|
33
|
-
required: true,
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
data() {
|
|
37
|
-
return {
|
|
38
|
-
selectedIndex: 0,
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
watch: {
|
|
42
|
-
items() {
|
|
43
|
-
this.selectedIndex = 0
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
methods: {
|
|
47
|
-
onKeyDown({ event }) {
|
|
48
|
-
if (event.key === 'ArrowUp') {
|
|
49
|
-
this.upHandler()
|
|
50
|
-
return true
|
|
51
|
-
}
|
|
52
|
-
if (event.key === 'ArrowDown') {
|
|
53
|
-
this.downHandler()
|
|
54
|
-
return true
|
|
55
|
-
}
|
|
56
|
-
if (event.key === 'Enter') {
|
|
57
|
-
this.enterHandler()
|
|
58
|
-
return true
|
|
59
|
-
}
|
|
60
|
-
return false
|
|
61
|
-
},
|
|
62
|
-
upHandler() {
|
|
63
|
-
this.selectedIndex =
|
|
64
|
-
(this.selectedIndex + this.items.length - 1) % this.items.length
|
|
65
|
-
},
|
|
66
|
-
downHandler() {
|
|
67
|
-
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
|
68
|
-
},
|
|
69
|
-
enterHandler() {
|
|
70
|
-
this.selectItem(this.selectedIndex)
|
|
71
|
-
},
|
|
72
|
-
selectItem(index) {
|
|
73
|
-
const item = this.items[index]
|
|
74
|
-
if (item) {
|
|
75
|
-
this.command({ emoji: item.emoji })
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
</script>
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { ref, type PropType } from 'vue'
|
|
17
|
+
import SuggestionList, { type SuggestionItem } from './SuggestionList.vue'
|
|
81
18
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
border-radius: 0.5rem;
|
|
86
|
-
box-shadow:
|
|
87
|
-
0 0 0 1px rgba(0, 0, 0, 0.05),
|
|
88
|
-
0px 10px 20px rgba(0, 0, 0, 0.1);
|
|
89
|
-
padding: 0.2rem;
|
|
19
|
+
interface EmojiItem extends SuggestionItem {
|
|
20
|
+
name: string
|
|
21
|
+
emoji: string
|
|
90
22
|
}
|
|
91
23
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
24
|
+
const props = defineProps({
|
|
25
|
+
items: {
|
|
26
|
+
type: Array as PropType<EmojiItem[]>,
|
|
27
|
+
required: true,
|
|
28
|
+
},
|
|
29
|
+
command: {
|
|
30
|
+
type: Function as PropType<(params: { emoji: string }) => void>,
|
|
31
|
+
required: true,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
99
34
|
|
|
100
|
-
|
|
101
|
-
background: #eee;
|
|
102
|
-
}
|
|
35
|
+
const suggestionList = ref<InstanceType<typeof SuggestionList> | null>(null)
|
|
103
36
|
|
|
104
|
-
|
|
105
|
-
|
|
37
|
+
const selectItem = (item: SuggestionItem) => {
|
|
38
|
+
if (item) {
|
|
39
|
+
props.command({ emoji: item.emoji })
|
|
40
|
+
}
|
|
106
41
|
}
|
|
107
42
|
|
|
108
|
-
|
|
109
|
-
|
|
43
|
+
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
|
|
44
|
+
return suggestionList.value?.onKeyDown({ event }) ?? false
|
|
110
45
|
}
|
|
111
|
-
|
|
46
|
+
|
|
47
|
+
defineExpose({
|
|
48
|
+
onKeyDown,
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
v-if="props.show"
|
|
12
12
|
class="fixed top-0 left-0 w-full h-full bg-black sm:bg-black/90 z-[50] flex flex-col justify-center items-center overflow-hidden touch-none"
|
|
13
13
|
ref="imageContainer"
|
|
14
|
+
@mousemove="handleActivity"
|
|
15
|
+
@touchstart="handleActivity"
|
|
16
|
+
@touchmove="handleActivity"
|
|
14
17
|
>
|
|
15
18
|
<!-- Dedicated Backdrop -->
|
|
16
19
|
<div
|
|
@@ -46,7 +49,8 @@
|
|
|
46
49
|
<!-- Caption -->
|
|
47
50
|
<div
|
|
48
51
|
v-if="currentImage.alt"
|
|
49
|
-
class="absolute bottom-4 p-2 text-center rounded-sm text-white text-sm bg-black/
|
|
52
|
+
class="absolute bottom-4 p-2 text-center rounded-sm text-white text-sm bg-black/65 z-10 transition-opacity duration-300 ease-in-out"
|
|
53
|
+
:class="{ 'opacity-0 pointer-events-none': !isControlsVisible }"
|
|
50
54
|
>
|
|
51
55
|
{{ currentImage.alt }}
|
|
52
56
|
</div>
|
|
@@ -54,7 +58,8 @@
|
|
|
54
58
|
<!-- Controls bar -->
|
|
55
59
|
<div
|
|
56
60
|
ref="controlsBar"
|
|
57
|
-
class="absolute top-4 flex items-center space-x-3 p-2 text-white z-20"
|
|
61
|
+
class="absolute top-4 flex items-center space-x-3 p-2 text-white z-20 transition-opacity duration-300 ease-in-out"
|
|
62
|
+
:class="{ 'opacity-0 pointer-events-none': !isControlsVisible }"
|
|
58
63
|
@touchstart.stop
|
|
59
64
|
@touchmove.stop
|
|
60
65
|
@touchend.stop
|
|
@@ -62,7 +67,7 @@
|
|
|
62
67
|
@wheel.stop
|
|
63
68
|
>
|
|
64
69
|
<!-- Navigation controls -->
|
|
65
|
-
<div class="bg-black
|
|
70
|
+
<div class="bg-black/65 rounded flex items-center">
|
|
66
71
|
<Tooltip text="Previous image">
|
|
67
72
|
<button
|
|
68
73
|
class="p-2 hover:bg-gray-900 rounded-l focus:outline-none"
|
|
@@ -117,7 +122,7 @@
|
|
|
117
122
|
</div>
|
|
118
123
|
|
|
119
124
|
<!-- Action controls -->
|
|
120
|
-
<div class="bg-black
|
|
125
|
+
<div class="bg-black/65 rounded flex items-center">
|
|
121
126
|
<Tooltip text="Download image">
|
|
122
127
|
<button
|
|
123
128
|
class="p-2 hover:bg-gray-900 rounded-l focus:outline-none"
|
|
@@ -141,7 +146,7 @@
|
|
|
141
146
|
</div>
|
|
142
147
|
|
|
143
148
|
<!-- Close button -->
|
|
144
|
-
<div class="bg-black
|
|
149
|
+
<div class="bg-black/65 rounded flex items-center">
|
|
145
150
|
<Tooltip text="Close">
|
|
146
151
|
<button
|
|
147
152
|
class="p-2 hover:bg-gray-900 rounded focus:outline-none"
|
|
@@ -204,6 +209,10 @@ const controlsBarHeight = ref(0)
|
|
|
204
209
|
const isFullscreen = ref(false)
|
|
205
210
|
const touchStartZoom = ref(100)
|
|
206
211
|
|
|
212
|
+
const isControlsVisible = ref(true)
|
|
213
|
+
const inactivityTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
214
|
+
const INACTIVITY_TIMEOUT = 3000 // 3 seconds
|
|
215
|
+
|
|
207
216
|
const {
|
|
208
217
|
zoomLevel,
|
|
209
218
|
panPosition,
|
|
@@ -297,9 +306,33 @@ const {
|
|
|
297
306
|
|
|
298
307
|
const isPanning = computed(() => isMousePanning.value || isTouchPanning.value)
|
|
299
308
|
|
|
309
|
+
function showControlsAndResetTimer() {
|
|
310
|
+
isControlsVisible.value = true
|
|
311
|
+
if (inactivityTimer.value) {
|
|
312
|
+
clearTimeout(inactivityTimer.value)
|
|
313
|
+
}
|
|
314
|
+
inactivityTimer.value = setTimeout(() => {
|
|
315
|
+
if (!isPanning.value && !isPinching.value) {
|
|
316
|
+
isControlsVisible.value = false
|
|
317
|
+
} else {
|
|
318
|
+
showControlsAndResetTimer()
|
|
319
|
+
}
|
|
320
|
+
}, INACTIVITY_TIMEOUT)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function handleActivity() {
|
|
324
|
+
if (!isPinching.value || !isControlsVisible.value) {
|
|
325
|
+
showControlsAndResetTimer()
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
300
329
|
function close() {
|
|
301
330
|
emit('update:show', false)
|
|
302
331
|
resetZoom()
|
|
332
|
+
if (inactivityTimer.value) {
|
|
333
|
+
clearTimeout(inactivityTimer.value)
|
|
334
|
+
inactivityTimer.value = null
|
|
335
|
+
}
|
|
303
336
|
}
|
|
304
337
|
|
|
305
338
|
function downloadImage() {
|
|
@@ -342,6 +375,8 @@ function handleFullscreenChange() {
|
|
|
342
375
|
function handleKeyDown(event: KeyboardEvent) {
|
|
343
376
|
if (!props.show) return
|
|
344
377
|
|
|
378
|
+
handleActivity()
|
|
379
|
+
|
|
345
380
|
switch (event.key) {
|
|
346
381
|
case 'ArrowLeft':
|
|
347
382
|
if (!isPanning.value) previousImage()
|
|
@@ -372,6 +407,25 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
409
|
|
|
410
|
+
watch(
|
|
411
|
+
() => props.show,
|
|
412
|
+
(newValue) => {
|
|
413
|
+
if (newValue) {
|
|
414
|
+
isControlsVisible.value = true
|
|
415
|
+
resetZoom()
|
|
416
|
+
showControlsAndResetTimer()
|
|
417
|
+
} else {
|
|
418
|
+
if (inactivityTimer.value) {
|
|
419
|
+
clearTimeout(inactivityTimer.value)
|
|
420
|
+
inactivityTimer.value = null
|
|
421
|
+
}
|
|
422
|
+
if (isFullscreen.value && document.exitFullscreen) {
|
|
423
|
+
document.exitFullscreen()
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
|
|
375
429
|
watch(controlsBar, (newVal) => {
|
|
376
430
|
if (newVal) {
|
|
377
431
|
const updateHeight = () => {
|
|
@@ -395,6 +449,10 @@ onUnmounted(() => {
|
|
|
395
449
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
396
450
|
document.removeEventListener('keydown', handleKeyDown)
|
|
397
451
|
|
|
452
|
+
if (inactivityTimer.value) {
|
|
453
|
+
clearTimeout(inactivityTimer.value)
|
|
454
|
+
}
|
|
455
|
+
|
|
398
456
|
if (isFullscreen.value && document.exitFullscreen) {
|
|
399
457
|
document.exitFullscreen()
|
|
400
458
|
}
|