Files
misskey/packages/frontend/src/components/MkSuperMenu.vue
syuilo 0214a0001f 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 41bffd3a13.

* 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>
2025-03-06 23:15:19 +09:00

357 lines
8.0 KiB
Vue

<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<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>
<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>
</template>
</div>
</template>
<script lang="ts">
export type SuperMenuDef = {
title?: string;
items: ({
type: 'a';
href: string;
target?: string;
icon?: string;
text: string;
danger?: boolean;
active?: boolean;
} | {
type: 'button';
icon?: string;
text: string;
danger?: boolean;
active?: boolean;
action: (ev: MouseEvent) => void | Promise<void>;
} | {
type?: 'link';
to: string;
icon?: string;
text: string;
danger?: boolean;
active?: boolean;
})[];
};
</script>
<script lang="ts" setup>
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>
.rrevdjwu {
> .group {
& + .group {
margin-top: 16px;
padding-top: 16px;
border-top: solid 0.5px var(--MI_THEME-divider);
}
> .title {
opacity: 0.7;
margin: 0 0 8px 0;
font-size: 0.9em;
}
> .items {
> .item {
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);
}
&:focus-visible {
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;
}
}
}
}
&.grid {
> .group {
margin-left: 0;
margin-right: 0;
& + .group {
padding-top: 0;
border-top: none;
}
> .title {
font-size: 1em;
opacity: 0.7;
margin: 0 0 8px 16px;
}
> .items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
grid-gap: 16px;
padding: 0 16px;
> .item {
flex-direction: column;
text-align: center;
padding: 0;
&:hover {
text-decoration: none;
background: none;
color: var(--MI_THEME-accent);
> .icon {
background: var(--MI_THEME-accentedBg);
}
}
> .icon {
display: grid;
place-content: center;
margin-right: 0;
margin-bottom: 6px;
font-size: 1.5em;
width: 60px;
height: 60px;
aspect-ratio: 1;
background: var(--MI_THEME-panel);
border-radius: 100%;
}
> .text {
padding-right: 0;
width: 100%;
font-size: 0.8em;
}
}
}
}
}
.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>