diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-03-06 23:15:19 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-06 23:15:19 +0900 |
| commit | 0214a0001fee2355a6d48da8ae5790c24650be33 (patch) | |
| tree | 64b8c3cd23dcfcdea9efc8b12ba583b949823149 /packages/frontend/src/components/MkSuperMenu.vue | |
| parent | [skip ci] Update CHANGELOG.md (prepend template) (diff) | |
| download | misskey-0214a0001fee2355a6d48da8ae5790c24650be33.tar.gz misskey-0214a0001fee2355a6d48da8ae5790c24650be33.tar.bz2 misskey-0214a0001fee2355a6d48da8ae5790c24650be33.zip | |
feat(frontend): 設定の検索 (#15505)
* wip
* wip
* wip
* test
* wip rollup pluginでsearchIndexの情報生成
* wip
* SPDX
* wip: markerIdを自動付与
* rollupでビルド時・devモード時に毎回uuidを生成するように
* 開発サーバーでだけ必要な挙動は開発サーバーのみで
* 条件が逆
* wip: childrenの生成
* update comment
* update comment
* rename auto generated file
* hashをパスと行数から決定
* Update privacy.vue
* Update privacy.vue
* wip
* Update general.vue
* Update general.vue
* wip
* wip
* Update SearchMarker.vue
* wip
* Update profile.vue
* Update mute-block.vue
* Update mute-block.vue
* Update general.vue
* Update general.vue
* childrenがduplicate key errorを吐く問題をいったん解決
* マーカーの形を成形
* loggerを置きかえ
* とりあえず省略記法に対応
* Refactor and Format codes
* wip
* Update settings-search-index.ts
* wip
* wip
* とりあえず不確定要因の仮置きidを削除
* hashの生成を正規化(絶対パスになっていたのを緩和)
* pathの入力を省略可能に
* adminでもパス生成できるように
* Update settings-search-index.ts
* Update privacy.vue
* wip
* build searchIndex
* wip
* build
* Update general.vue
* build
* Update sounds.vue
* build
* build
* Update sounds.vue
* 🎨
* 🎨
* Update privacy.vue
* Update privacy.vue
* Update security.vue
* create-search-indexを多少改善
* build
* Update 2fa.vue
* wip
* 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義
* キャッシュはdevServerでなくても更新
* Revert "wip"
This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054.
* inlining
* wip
* Update theme.vue
* 🎨
* wip normalize
* Update theme.vue
* キャッシュのパス変換
* build
* wip
* wip
* Update SearchMarker.vue
* i18n.ts['key'] の形式が取り出せない問題のFix
* build
* 仮でpath入れ
* 必ず絶対パスが使われるように
* wip
* 🎨
* storybookビルド時はcreateSearchIndexをしない
* inliningの構造化
* format code
* Update index.vue
* wip
* wip
* 🎨
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* clean up
* Update navbar.vue
* enhance: 検索で上下矢印を使用することで検索結果を移動できるように
* refactor
* fix(frontend): PageWindowでSearchMarkerが動作するように
* enhance(frontend): SearchMarkerの点滅を一定時間で止める
* lint fix
* fix: 子要素監視が抜けていたのを修正
* アニメーションの回数はCSSで制御するように
* refactor
* enhance(frontend): 検索インデックス作成時のログを削減
* revert
* fix
* fix
---------
Co-authored-by: tai-cha <dev@taichan.site>
Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com>
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src/components/MkSuperMenu.vue')
| -rw-r--r-- | packages/frontend/src/components/MkSuperMenu.vue | 208 |
1 files changed, 188 insertions, 20 deletions
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 397aa68ed6..d8dec3aa2f 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -4,27 +4,60 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="rrevdjwu" :class="{ grid }"> - <div v-for="group in def" class="group"> - <div v-if="group.title" class="title">{{ group.title }}</div> +<div ref="rootEl" class="rrevdjwu" :class="{ grid }"> + <MkInput + v-model="search" + :placeholder="i18n.ts.search" + type="search" + style="margin-bottom: 16px;" + @keydown="searchOnKeyDown" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> - <div class="items"> - <template v-for="(item, i) in group.items"> - <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </a> - <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </button> - <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> - <span class="text">{{ item.text }}</span> - </MkA> - </template> + <template v-if="search == ''"> + <div v-for="group in def" class="group"> + <div v-if="group.title" class="title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> + </template> + <template v-else> + <div v-for="item, index in searchResult"> + <MkA + :to="item.path + '#' + item.id" + class="_button searchResultItem" + :class="{ selected: searchSelectedIndex !== null && searchSelectedIndex === index }" + > + <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> + <span class="text"> + <template v-if="item.isRoot"> + {{ item.label }} + </template> + <template v-else> + <span style="opacity: 0.7; font-size: 90%;">{{ item.parentLabels.join(' > ') }}</span> + <br> + <span>{{ item.label }}</span> + </template> + </span> + </MkA> </div> - </div> + </template> </div> </template> @@ -58,10 +91,98 @@ export type SuperMenuDef = { </script> <script lang="ts" setup> -defineProps<{ +import { useTemplateRef, ref, watch, nextTick } from 'vue'; +import type { SearchIndexItem } from '@/scripts/autogen/settings-search-index.js'; +import MkInput from '@/components/MkInput.vue'; +import { i18n } from '@/i18n.js'; +import { getScrollContainer } from '@@/js/scroll.js'; +import { useRouter } from '@/router/supplier.js'; + +const props = defineProps<{ def: SuperMenuDef[]; grid?: boolean; + searchIndex: SearchIndexItem[]; }>(); + +const router = useRouter(); +const rootEl = useTemplateRef('rootEl'); + +const search = ref(''); +const searchSelectedIndex = ref<null | number>(null); +const searchResult = ref<{ + id: string; + path: string; + label: string; + icon?: string; + isRoot: boolean; + parentLabels: string[]; +}[]>([]); + +watch(search, (value) => { + searchResult.value = []; + searchSelectedIndex.value = null; + + if (value === '') { + return; + } + + const dive = (items: SearchIndexItem[], parents: SearchIndexItem[] = []) => { + for (const item of items) { + const matched = + item.label.includes(value.toLowerCase()) || + item.keywords.some((x) => x.toLowerCase().includes(value.toLowerCase())); + + if (matched) { + searchResult.value.push({ + id: item.id, + path: item.path ?? parents.find((x) => x.path != null)?.path, + label: item.label, + parentLabels: parents.map((x) => x.label).toReversed(), + icon: item.icon ?? parents.find((x) => x.icon != null)?.icon, + isRoot: parents.length === 0, + }); + } + + if (item.children) { + dive(item.children, [item, ...parents]); + } + } + }; + + dive(props.searchIndex); +}); + +function searchOnKeyDown(ev: KeyboardEvent) { + if (ev.isComposing) return; + + if (ev.key === 'Enter' && searchSelectedIndex.value != null) { + ev.preventDefault(); + router.push(searchResult.value[searchSelectedIndex.value].path + '#' + searchResult.value[searchSelectedIndex.value].id); + } else if (ev.key === 'ArrowDown') { + ev.preventDefault(); + const current = searchSelectedIndex.value ?? -1; + searchSelectedIndex.value = current + 1 >= searchResult.value.length ? 0 : current + 1; + } else if (ev.key === 'ArrowUp') { + ev.preventDefault(); + const current = searchSelectedIndex.value ?? 0; + searchSelectedIndex.value = current - 1 < 0 ? searchResult.value.length - 1 : current - 1; + } + + if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { + nextTick(() => { + if (!rootEl.value) return; + const selectedEl = rootEl.value.querySelector<HTMLElement>('.searchResultItem.selected'); + if (selectedEl != null) { + const scrollContainer = getScrollContainer(selectedEl); + if (!scrollContainer) return; + scrollContainer.scrollTo({ + top: selectedEl.offsetTop - scrollContainer.clientHeight / 2 + selectedEl.clientHeight / 2, + behavior: 'instant', + }); + } + }); + } +} </script> <style lang="scss" scoped> @@ -184,5 +305,52 @@ defineProps<{ } } } + + .searchResultItem { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 9px 16px 9px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--MI_THEME-panelHighlight); + } + + &.selected { + outline: 2px solid var(--MI_THEME-focus); + } + + &:focus-visible, + &.selected { + outline-offset: -2px; + } + + &.active { + color: var(--MI_THEME-accent); + background: var(--MI_THEME-accentedBg); + } + + &.danger { + color: var(--MI_THEME-error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: normal; + padding-right: 12px; + flex-shrink: 1; + } + } } </style> |