diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-03-09 03:29:58 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-09 03:29:58 +0000 |
| commit | bef73ff530e84418c8257aff558b677a4aa71064 (patch) | |
| tree | e2a64f47e2c1c60b34fb240cb85aff308cc3bb59 /packages/frontend/src/components | |
| parent | Merge pull request #15585 from misskey-dev/develop (diff) | |
| parent | Release: 2025.3.1 (diff) | |
| download | misskey-bef73ff530e84418c8257aff558b677a4aa71064.tar.gz misskey-bef73ff530e84418c8257aff558b677a4aa71064.tar.bz2 misskey-bef73ff530e84418c8257aff558b677a4aa71064.zip | |
Merge pull request #15615 from misskey-dev/develop
Release: 2025.3.1
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkDisableSection.vue | 42 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPageWindow.vue | 12 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSuperMenu.vue | 208 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/SearchKeyword.vue | 14 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/SearchLabel.vue | 14 | ||||
| -rw-r--r-- | packages/frontend/src/components/global/SearchMarker.vue | 116 | ||||
| -rw-r--r-- | packages/frontend/src/components/index.ts | 13 |
7 files changed, 396 insertions, 23 deletions
diff --git a/packages/frontend/src/components/MkDisableSection.vue b/packages/frontend/src/components/MkDisableSection.vue new file mode 100644 index 0000000000..bd7ecf225d --- /dev/null +++ b/packages/frontend/src/components/MkDisableSection.vue @@ -0,0 +1,42 @@ +<!-- +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.3; + filter: saturate(0.5); +} + +.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> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index e725d2a15d..c3fc1961eb 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -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; 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> diff --git a/packages/frontend/src/components/global/SearchKeyword.vue b/packages/frontend/src/components/global/SearchKeyword.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchKeyword.vue @@ -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> diff --git a/packages/frontend/src/components/global/SearchLabel.vue b/packages/frontend/src/components/global/SearchLabel.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchLabel.vue @@ -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> diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue new file mode 100644 index 0000000000..c5ec626cf4 --- /dev/null +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -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> diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 0252bf0252..ebbad3e5b8 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -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; } } |