summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-03-09 03:29:58 +0000
committerGitHub <noreply@github.com>2025-03-09 03:29:58 +0000
commitbef73ff530e84418c8257aff558b677a4aa71064 (patch)
treee2a64f47e2c1c60b34fb240cb85aff308cc3bb59 /packages/frontend/src/components
parentMerge pull request #15585 from misskey-dev/develop (diff)
parentRelease: 2025.3.1 (diff)
downloadmisskey-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.vue42
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue12
-rw-r--r--packages/frontend/src/components/MkSuperMenu.vue208
-rw-r--r--packages/frontend/src/components/global/SearchKeyword.vue14
-rw-r--r--packages/frontend/src/components/global/SearchLabel.vue14
-rw-r--r--packages/frontend/src/components/global/SearchMarker.vue116
-rw-r--r--packages/frontend/src/components/index.ts13
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;
}
}