diff options
Diffstat (limited to 'viewer/src/views/layout/left/LayoutTagInput.vue')
-rw-r--r-- | viewer/src/views/layout/left/LayoutTagInput.vue | 141 |
1 files changed, 141 insertions, 0 deletions
diff --git a/viewer/src/views/layout/left/LayoutTagInput.vue b/viewer/src/views/layout/left/LayoutTagInput.vue new file mode 100644 index 0000000..a37c546 --- /dev/null +++ b/viewer/src/views/layout/left/LayoutTagInput.vue | |||
@@ -0,0 +1,141 @@ | |||
1 | <!-- ldgallery - A static generator which turns a collection of tagged | ||
2 | -- pictures into a searchable web gallery. | ||
3 | -- | ||
4 | -- Copyright (C) 2019-2022 Guillaume FOUET | ||
5 | -- | ||
6 | -- This program is free software: you can redistribute it and/or modify | ||
7 | -- it under the terms of the GNU Affero General Public License as | ||
8 | -- published by the Free Software Foundation, either version 3 of the | ||
9 | -- License, or (at your option) any later version. | ||
10 | -- | ||
11 | -- This program is distributed in the hope that it will be useful, | ||
12 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
13 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
14 | -- GNU Affero General Public License for more details. | ||
15 | -- | ||
16 | -- You should have received a copy of the GNU Affero General Public License | ||
17 | -- along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
18 | --> | ||
19 | |||
20 | <template> | ||
21 | <LdInput | ||
22 | ref="input" | ||
23 | v-model="search" | ||
24 | :placeholder="t('tagInput.placeholder')" | ||
25 | :tabindex="50" | ||
26 | @focus="e => (e.target as HTMLInputElement).select()" | ||
27 | @keypress.enter="inputEnter" | ||
28 | @keydown.backspace="inputBackspace" | ||
29 | /> | ||
30 | <LdDropdown | ||
31 | ref="dropdown" | ||
32 | v-model="showDropdown" | ||
33 | :list="filteredTags" | ||
34 | list-key="tagfiltered" | ||
35 | :tabindex-root="51" | ||
36 | :class="$style.dropdown" | ||
37 | :style="dropdownStyle" | ||
38 | @select="addTag" | ||
39 | @opening="emit('opening')" | ||
40 | @closing="cleanSearch(); emit('closing');" | ||
41 | > | ||
42 | <template #option="{option}:{option:TagSearch}"> | ||
43 | <div v-text="option.display" /> | ||
44 | <div v-text="option.items.length" /> | ||
45 | </template> | ||
46 | <template #empty> | ||
47 | <div | ||
48 | :class="$style.nomatch" | ||
49 | v-text="t('tagInput.nomatch')" | ||
50 | /> | ||
51 | </template> | ||
52 | </LdDropdown> | ||
53 | </template> | ||
54 | |||
55 | <script setup lang="ts"> | ||
56 | import { TagSearch } from '@/@types/tag'; | ||
57 | import LdDropdown from '@/components/LdDropdown.vue'; | ||
58 | import LdInput from '@/components/LdInput.vue'; | ||
59 | import { useIndexFactory } from '@/services/indexFactory'; | ||
60 | import { useGalleryStore } from '@/store/galleryStore'; | ||
61 | import { computedEager, useElementBounding, useFocus, useVModel } from '@vueuse/core'; | ||
62 | import { computed, ref, StyleValue, watchEffect } from 'vue'; | ||
63 | import { useI18n } from 'vue-i18n'; | ||
64 | |||
65 | const props = defineProps({ | ||
66 | modelValue: { type: Array<TagSearch>, required: true }, | ||
67 | }); | ||
68 | const emit = defineEmits(['update:modelValue', 'search', 'opening', 'closing']); | ||
69 | const model = useVModel(props, 'modelValue', emit); | ||
70 | |||
71 | const { t } = useI18n(); | ||
72 | const galeryStore = useGalleryStore(); | ||
73 | const indexFactory = useIndexFactory(); | ||
74 | |||
75 | const search = ref(''); | ||
76 | const showDropdown = ref(false); | ||
77 | |||
78 | watchEffect(() => (showDropdown.value = !!search.value)); | ||
79 | |||
80 | // --- | ||
81 | |||
82 | const dropdown = ref(); | ||
83 | const { top } = useElementBounding(dropdown); | ||
84 | const dropdownStyle = computedEager<StyleValue>(() => ({ height: `calc(100vh - 8px - ${top.value}px)` })); | ||
85 | |||
86 | const input = ref(); | ||
87 | const { focused } = useFocus(input); | ||
88 | |||
89 | // --- | ||
90 | |||
91 | const filteredTags = computed(() => indexFactory.searchTags(galeryStore.tagsIndex, search.value, false) | ||
92 | .filter(filterAlreadyPresent) | ||
93 | .sort((a, b) => b.items.length - a.items.length)); | ||
94 | |||
95 | function filterAlreadyPresent(newSearch: TagSearch) { | ||
96 | return !model.value.find( | ||
97 | currentSearch => | ||
98 | currentSearch.tag === newSearch.tag && (!currentSearch.parent || currentSearch.parent === newSearch.parent), | ||
99 | ); | ||
100 | } | ||
101 | |||
102 | function addTag(tag?: TagSearch) { | ||
103 | const toPush = tag ?? filteredTags.value[0]; | ||
104 | if (!toPush) return; | ||
105 | model.value.push(toPush); | ||
106 | cleanSearch(); | ||
107 | } | ||
108 | function inputEnter() { | ||
109 | if (search.value) addTag(); | ||
110 | else emit('search'); | ||
111 | } | ||
112 | function inputBackspace() { | ||
113 | !showDropdown.value && model.value.pop(); | ||
114 | } | ||
115 | function cleanSearch() { | ||
116 | search.value = ''; | ||
117 | focused.value = true; | ||
118 | } | ||
119 | </script> | ||
120 | |||
121 | <style lang="scss" module> | ||
122 | @import "~@/assets/scss/theme"; | ||
123 | |||
124 | .dropdown { | ||
125 | > div { | ||
126 | display: flex; | ||
127 | justify-content: space-between; | ||
128 | > div { | ||
129 | padding: 0 4px; | ||
130 | } | ||
131 | > div:last-child { | ||
132 | color: $text-light; | ||
133 | } | ||
134 | } | ||
135 | .nomatch { | ||
136 | color: $disabled-color; | ||
137 | justify-content: center; | ||
138 | cursor: default; | ||
139 | } | ||
140 | } | ||
141 | </style> | ||