summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkSuperMenu.vue
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-03-06 23:15:19 +0900
committerGitHub <noreply@github.com>2025-03-06 23:15:19 +0900
commit0214a0001fee2355a6d48da8ae5790c24650be33 (patch)
tree64b8c3cd23dcfcdea9efc8b12ba583b949823149 /packages/frontend/src/components/MkSuperMenu.vue
parent[skip ci] Update CHANGELOG.md (prepend template) (diff)
downloadmisskey-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.vue208
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>