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>
This commit is contained in:
parent
46067f6e17
commit
0214a0001f
36 changed files with 4706 additions and 1402 deletions
41
packages/frontend/src/components/MkDisableSection.vue
Normal file
41
packages/frontend/src/components/MkDisableSection.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root]">
|
||||
<div :inert="disabled" :class="[{ [$style.disabled]: disabled }]">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="disabled" :class="[$style.cover]"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: not-allowed;
|
||||
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -91,6 +91,14 @@ const buttonsRight = computed(() => {
|
|||
});
|
||||
const reloadCount = ref(0);
|
||||
|
||||
function getSearchMarker(path: string) {
|
||||
const hash = path.split('#')[1];
|
||||
if (hash == null) return null;
|
||||
return hash;
|
||||
}
|
||||
|
||||
const searchMarkerId = ref<string | null>(getSearchMarker(props.initialPath));
|
||||
|
||||
windowRouter.addListener('push', ctx => {
|
||||
history.value.push({ path: ctx.path, key: ctx.key });
|
||||
});
|
||||
|
|
@ -101,7 +109,8 @@ windowRouter.addListener('replace', ctx => {
|
|||
});
|
||||
|
||||
windowRouter.addListener('change', ctx => {
|
||||
console.log('windowRouter: change', ctx.path);
|
||||
if (_DEV_) console.log('windowRouter: change', ctx.path);
|
||||
searchMarkerId.value = getSearchMarker(ctx.path);
|
||||
analytics.page({
|
||||
path: ctx.path,
|
||||
title: ctx.path,
|
||||
|
|
@ -111,6 +120,7 @@ windowRouter.addListener('change', ctx => {
|
|||
windowRouter.init();
|
||||
|
||||
provide('router', windowRouter);
|
||||
provide('inAppSearchMarkerId', searchMarkerId);
|
||||
provideMetadataReceiver((metadataGetter) => {
|
||||
const info = metadataGetter();
|
||||
pageMetadata.value = info;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
14
packages/frontend/src/components/global/SearchKeyword.vue
Normal file
14
packages/frontend/src/components/global/SearchKeyword.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<slot></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
</style>
|
||||
14
packages/frontend/src/components/global/SearchLabel.vue
Normal file
14
packages/frontend/src/components/global/SearchLabel.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<slot></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
</style>
|
||||
116
packages/frontend/src/components/global/SearchMarker.vue
Normal file
116
packages/frontend/src/components/global/SearchMarker.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div ref="root" :class="[$style.root, { [$style.highlighted]: highlighted }]">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
computed,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
inject,
|
||||
} from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
markerId?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
keywords?: string[];
|
||||
children?: string[];
|
||||
inlining?: string[];
|
||||
}>();
|
||||
|
||||
const rootEl = useTemplateRef('root');
|
||||
const rootElMutationObserver = new MutationObserver(() => {
|
||||
checkChildren();
|
||||
});
|
||||
const injectedSearchMarkerId = inject<Ref<string | null>>('inAppSearchMarkerId');
|
||||
const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1));
|
||||
const highlighted = ref(props.markerId === searchMarkerId.value);
|
||||
|
||||
function checkChildren() {
|
||||
if (props.children?.includes(searchMarkerId.value)) {
|
||||
const el = document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`);
|
||||
highlighted.value = el == null;
|
||||
}
|
||||
}
|
||||
|
||||
watch([
|
||||
searchMarkerId,
|
||||
() => props.children,
|
||||
], () => {
|
||||
if (props.children != null && props.children.length > 0) {
|
||||
checkChildren();
|
||||
}
|
||||
}, { flush: 'post' });
|
||||
|
||||
function init() {
|
||||
checkChildren();
|
||||
|
||||
if (highlighted.value) {
|
||||
rootEl.value?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
if (rootEl.value != null) {
|
||||
rootElMutationObserver.observe(rootEl.value, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
rootElMutationObserver.disconnect();
|
||||
}
|
||||
|
||||
onMounted(init);
|
||||
onActivated(init);
|
||||
onDeactivated(dispose);
|
||||
onBeforeUnmount(dispose);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: calc(100% + 16px);
|
||||
height: calc(100% + 16px);
|
||||
border-radius: 6px;
|
||||
animation: blink 1s 3.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% {
|
||||
background: color(from var(--MI_THEME-accent) srgb r g b / 0.05);
|
||||
border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.7);
|
||||
}
|
||||
50% {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,8 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
import Mfm from './global/MkMfm.js';
|
||||
import MkA from './global/MkA.vue';
|
||||
import MkAcct from './global/MkAcct.vue';
|
||||
|
|
@ -26,6 +24,11 @@ import MkSpacer from './global/MkSpacer.vue';
|
|||
import MkFooterSpacer from './global/MkFooterSpacer.vue';
|
||||
import MkStickyContainer from './global/MkStickyContainer.vue';
|
||||
import MkLazy from './global/MkLazy.vue';
|
||||
import SearchMarker from './global/SearchMarker.vue';
|
||||
import SearchLabel from './global/SearchLabel.vue';
|
||||
import SearchKeyword from './global/SearchKeyword.vue';
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
export default function(app: App) {
|
||||
for (const [key, value] of Object.entries(components)) {
|
||||
|
|
@ -55,6 +58,9 @@ export const components = {
|
|||
MkFooterSpacer: MkFooterSpacer,
|
||||
MkStickyContainer: MkStickyContainer,
|
||||
MkLazy: MkLazy,
|
||||
SearchMarker: SearchMarker,
|
||||
SearchLabel: SearchLabel,
|
||||
SearchKeyword: SearchKeyword,
|
||||
};
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
|
|
@ -80,5 +86,8 @@ declare module '@vue/runtime-core' {
|
|||
MkFooterSpacer: typeof MkFooterSpacer;
|
||||
MkStickyContainer: typeof MkStickyContainer;
|
||||
MkLazy: typeof MkLazy;
|
||||
SearchMarker: typeof SearchMarker;
|
||||
SearchLabel: typeof SearchLabel;
|
||||
SearchKeyword: typeof SearchKeyword;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue