diff options
Diffstat (limited to 'packages/frontend/src/components')
125 files changed, 2007 insertions, 1287 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index c7252e7c98..cbc5b27fca 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -115,7 +115,7 @@ watch(moderationNote, async () => { }); }); -function resolve(resolvedAs) { +function resolve(resolvedAs: 'accept' | 'reject' | null) { os.apiWithDialog('admin/resolve-abuse-user-report', { reportId: props.report.id, resolvedAs, @@ -132,7 +132,7 @@ function forward() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu([{ icon: 'ti ti-hash', text: 'Copy ID', diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index c786e9fe9f..fe6415eabb 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -23,13 +23,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.body"> <div :class="$style.header"> - <span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span> + <span :class="$style.title">{{ i18n.ts._achievements._types[`_${achievement.name}`].title }}</span> <span :class="$style.time"> <time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time> </span> </div> - <div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types['_' + achievement.name].description : '???' }}</div> - <div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor && withDescription" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div> + <div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types[`_${achievement.name}`].description : '???' }}</div> + <div v-if="'flavor' in i18n.ts._achievements._types[`_${achievement.name}`] && withDescription" :class="$style.flavor">{{ (i18n.ts._achievements._types[`_${achievement.name}`] as { flavor: string; }).flavor }}</div> </div> </div> <template v-if="withLocked"> @@ -54,7 +54,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { onMounted, ref, computed } from 'vue'; -import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js'; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 81c92bfb5c..da0f618e95 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="$emit('closed')" @click="onBgClick"> +<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="emit('closed')" @click="onBgClick"> <div ref="rootEl" :class="$style.root"> <div :class="$style.header"> <span :class="$style.icon"> @@ -44,6 +44,10 @@ const props = defineProps<{ announcement: Misskey.entities.Announcement; }>(); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const rootEl = useTemplateRef('rootEl'); const bottomEl = useTemplateRef('bottomEl'); const modal = useTemplateRef('modal'); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index a3b6112629..c66e9d176a 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -64,13 +64,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import type { Ref } from 'vue'; +import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import * as os from '@/os.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import { useMkSelect } from '@/composables/use-mkselect.js'; @@ -106,7 +106,7 @@ const containerStyle = computed(() => { const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle; const border = isBordered ? { - borderWidth: c.borderWidth ?? '1px', + borderWidth: `${c.borderWidth ?? 1}px`, borderColor: c.borderColor ?? 'var(--MI_THEME-divider)', borderStyle: c.borderStyle ?? 'solid', } : undefined; @@ -144,7 +144,7 @@ const { initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null, }); -function onSelectUpdate(v) { +function onSelectUpdate(v: string | null) { valueForSelect.value = v; if ('onChange' in c && c.onChange) { c.onChange(v as never); diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 8744b50926..b1a29660ad 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -183,7 +183,7 @@ async function init() { init(); -function clickAddAccount(ev: MouseEvent) { +function clickAddAccount(ev: PointerEvent) { selectedUser.value = null; os.popupMenu([{ diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index 15aab8daed..9104650752 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; import MkInput from './MkInput.vue'; +import type { StoryObj } from '@storybook/vue3'; import { tick } from '@/utility/test-utils.js'; const common = { render(args) { @@ -81,7 +80,7 @@ export const User = { ...common.args, type: 'user', }, - async play({ canvasElement }) { + async play({ canvasElement }: { canvasElement: HTMLElement }) { const canvas = within(canvasElement); const input = canvas.getByRole('combobox'); await waitFor(() => userEvent.hover(input)); @@ -114,7 +113,7 @@ export const Hashtag = { ...common.args, type: 'hashtag', }, - async play({ canvasElement }) { + async play({ canvasElement }: { canvasElement: HTMLElement }) { const canvas = within(canvasElement); const input = canvas.getByRole('combobox'); await waitFor(() => userEvent.hover(input)); diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index cf5d95e11b..bfe66cdf8f 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -45,12 +45,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import sanitizeHtml from 'sanitize-html'; import { emojilist, getEmojiName } from '@@/js/emojilist.js'; import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js'; import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; import type { EmojiDef } from '@/utility/search-emoji.js'; -import contains from '@/utility/contains.js'; +import { elementContains } from '@/utility/element-contains.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -63,7 +64,7 @@ import { prefer } from '@/preferences.js'; export type CompleteInfo = { user: { - payload: any; + payload: Misskey.entities.User; query: string | null; }, hashtag: { @@ -185,9 +186,9 @@ const suggests = ref<Element>(); const rootEl = useTemplateRef('rootEl'); const fetching = ref(true); -const users = ref<any[]>([]); -const hashtags = ref<any[]>([]); -const emojis = ref<(EmojiDef)[]>([]); +const users = ref<Misskey.entities.User[]>([]); +const hashtags = ref<string[]>([]); +const emojis = ref<EmojiDef[]>([]); const items = ref<Element[] | HTMLCollection>([]); const mfmTags = ref<string[]>([]); const mfmParams = ref<string[]>([]); @@ -204,8 +205,8 @@ function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T][ emit('closed'); if (type === 'emoji' || type === 'emojiComplete') { let recents = store.s.recentlyUsedEmojis; - recents = recents.filter((emoji: any) => emoji !== value); - recents.unshift(value); + recents = recents.filter((emoji) => emoji !== value); + recents.unshift(value as string); store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -254,7 +255,7 @@ function exec() { limit: 10, detail: false, }).then(searchedUsers => { - users.value = searchedUsers as any[]; + users.value = searchedUsers; fetching.value = false; // キャッシュ sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers)); @@ -276,7 +277,7 @@ function exec() { query: props.q, limit: 30, }).then(searchedHashtags => { - hashtags.value = searchedHashtags as any[]; + hashtags.value = searchedHashtags; fetching.value = false; // キャッシュ sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags)); @@ -310,8 +311,8 @@ function exec() { } } -function onMousedown(event: Event) { - if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); +function onMousedown(event: MouseEvent) { + if (!elementContains(rootEl.value, event.target as Element) && (rootEl.value !== event.target)) props.close(); } function onKeydown(event: KeyboardEvent) { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index b729128a21..854ed31ed5 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -63,7 +63,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'click', payload: MouseEvent): void; + (ev: 'click', payload: PointerEvent): void; }>(); const el = useTemplateRef('el'); @@ -77,11 +77,11 @@ onMounted(() => { } }); -function distance(p, q): number { +function distance(p: { x: number; y: number }, q: { x: number; y: number }): number { return Math.hypot(p.x - q.x, p.y - q.y); } -function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number { +function calcCircleScale(boxW: number, boxH: number, circleCenterX: number, circleCenterY: number): number { const origin = { x: circleCenterX, y: circleCenterY }; const dist1 = distance({ x: 0, y: 0 }, origin); const dist2 = distance({ x: boxW, y: 0 }, origin); diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 30940a34a9..2fa1135398 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -7,8 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <span v-if="!available">Loading<MkEllipsis/></span> <div v-if="props.provider == 'mcaptcha'"> - <div id="mcaptcha__widget-container" class="m-captcha-style"></div> - <div ref="captchaEl"></div> + <iframe + v-if="mCaptchaIframeUrl != null" + ref="mCaptchaIframe" + :src="mCaptchaIframeUrl" + style="border: none; max-width: 320px; width: 100%; height: 100%; max-height: 80px;" + ></iframe> </div> <div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;"> <img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/> @@ -26,7 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted, nextTick } from 'vue'; +import type Reciever_typeReferenceOnly from '@mcaptcha/core-glue'; import { store } from '@/store.js'; // APIs provided by Captcha services @@ -71,6 +76,19 @@ const available = ref(false); const captchaEl = useTemplateRef('captchaEl'); const captchaWidgetId = ref<string | undefined>(undefined); + +let mCaptchaReciever: Reciever_typeReferenceOnly | null = null; +const mCaptchaIframe = useTemplateRef('mCaptchaIframe'); +const mCaptchaRemoveState = ref(false); +const mCaptchaIframeUrl = computed(() => { + if (props.provider === 'mcaptcha' && !mCaptchaRemoveState.value && props.instanceUrl && props.sitekey) { + const url = new URL('/widget', props.instanceUrl); + url.searchParams.set('sitekey', props.sitekey); + return url.toString(); + } + return null; +}); + const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -84,7 +102,7 @@ const variable = computed(() => { } }); -const loaded = !!window[variable.value]; +const loaded = !!(window as any)[variable.value]; const src = computed(() => { switch (props.provider) { @@ -98,7 +116,7 @@ const src = computed(() => { const scriptId = computed(() => `script-${props.provider}`); -const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); +const captcha = computed<Captcha>(() => (window as any)[variable.value] ?? {} as unknown as Captcha); watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { // 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない @@ -129,8 +147,14 @@ function reset() { if (_DEV_) console.warn(error); } } + testcaptchaPassed.value = false; testcaptchaInput.value = ''; + + if (mCaptchaReciever != null) { + mCaptchaReciever.destroy(); + mCaptchaReciever = null; + } } function remove() { @@ -143,6 +167,10 @@ function remove() { if (_DEV_) console.warn(error); } } + + if (props.provider === 'mcaptcha') { + mCaptchaRemoveState.value = true; + } } async function requestRender() { @@ -160,32 +188,29 @@ async function requestRender() { 'error-callback': () => callback(undefined), }); } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { - const { default: Widget } = await import('@mcaptcha/vanilla-glue'); - new Widget({ + const { default: Reciever } = await import('@mcaptcha/core-glue'); + mCaptchaReciever = new Reciever({ siteKey: { - instanceUrl: new URL(props.instanceUrl), key: props.sitekey, + instanceUrl: new URL(props.instanceUrl), }, + }, (token: string) => { + callback(token); }); + mCaptchaReciever.listen(); + mCaptchaRemoveState.value = false; } else { - window.setTimeout(requestRender, 1); + window.setTimeout(requestRender, 50); } } function clearWidget() { - if (props.provider === 'mcaptcha') { - const container = window.document.getElementById('mcaptcha__widget-container'); - if (container) { - container.innerHTML = ''; - } - } else { - reset(); - remove(); + reset(); + remove(); - if (captchaEl.value) { - // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止 - captchaEl.value.innerHTML = ''; - } + if (captchaEl.value) { + // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止 + captchaEl.value.innerHTML = ''; } } diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 23bb32c6b9..af89ec8252 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -24,6 +24,6 @@ const props = withDefaults(defineProps<{ noGap?: boolean; extractor?: ExtractorFunction<P, Misskey.entities.Channel>; }>(), { - extractor: (item) => item, + extractor: (item: any) => item as Misskey.entities.Channel, }); </script> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index c54081ad42..e418e729ca 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -94,8 +94,8 @@ const props = withDefaults(defineProps<{ const legendEl = useTemplateRef('legendEl'); -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); +const sum = (...arr: number[][]) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = (arr: number[]) => arr.map((x) => -x); const colors = { blue: '#008FFB', @@ -108,7 +108,7 @@ const colors = { cyan: '#00e0e0', }; const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple]; -const getColor = (i) => { +const getColor = (i: number) => { return colorSets[i % colorSets.length]; }; @@ -142,7 +142,7 @@ const getDate = (ago: number) => { return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); }; -const format = (arr) => { +const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, @@ -371,7 +371,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => { }; }; -const fetchNotesChart = async (type: string): Promise<typeof chartData> => { +const fetchNotesChart = async (type: 'local' | 'remote' | 'combined'): Promise<typeof chartData> => { const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 775964af50..0c856c57eb 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { useInterval } from '@@/js/use-interval.js'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; -import { useInterval } from '@@/js/use-interval.js'; import * as game from '@/utility/clicker-game.js'; import number from '@/filters/number.js'; import { claimAchievement } from '@/utility/achievements.js'; @@ -32,7 +32,7 @@ const cookies = computed(() => saveData.value?.cookies); const cps = ref(0); const prevCookies = ref(0); -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { const x = ev.clientX; const y = ev.clientY; const { dispose } = os.popup(MkPlusOneEffect, { x, y }, { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index bdb2ba6a44..dda5a14716 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -40,7 +40,7 @@ import XCode from '@/components/MkCode.core.vue'; const props = withDefaults(defineProps<{ modelValue: string | null; - lang: string; + lang?: string; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -51,7 +51,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'change', _ev: InputEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter'): void; (ev: 'update:modelValue', value: string): void; @@ -63,15 +63,17 @@ const focused = ref(false); const changed = ref(false); const inputEl = useTemplateRef('inputEl'); -const focus = () => inputEl.value?.focus(); +function focus() { + inputEl.value?.focus(); +} -const onInput = (ev) => { - v.value = ev.target?.value ?? v.value; +function onInput(ev: InputEvent) { + v.value = (inputEl.value?.value) ?? ''; changed.value = true; emit('change', ev); -}; +} -const onKeydown = (ev: KeyboardEvent) => { +function onKeydown(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; emit('keydown', ev); @@ -102,12 +104,12 @@ const onKeydown = (ev: KeyboardEvent) => { }); ev.preventDefault(); } -}; +} -const updated = () => { +function updated() { changed.value = false; emit('update:modelValue', v.value); -}; +} const debouncedUpdated = debounce(1000, updated); diff --git a/packages/frontend/src/components/MkContextMenu.stories.impl.ts b/packages/frontend/src/components/MkContextMenu.stories.impl.ts index 7a5e36131b..fc9fd9bc49 100644 --- a/packages/frontend/src/components/MkContextMenu.stories.impl.ts +++ b/packages/frontend/src/components/MkContextMenu.stories.impl.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; import { userEvent, within } from '@storybook/test'; import MkContextMenu from './MkContextMenu.vue'; +import type { StoryObj } from '@storybook/vue3'; import * as os from '@/os.js'; export const Empty = { render(args) { @@ -25,7 +23,7 @@ export const Empty = { }, }, methods: { - onContextmenu(ev: MouseEvent) { + onContextmenu(ev: PointerEvent) { os.contextMenu(args.items, ev); }, }, diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 9c6397a72c..6678c8fb91 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/utility/contains.js'; +import { elementContains } from '@/utility/element-contains.js'; import { prefer } from '@/preferences.js'; import * as os from '@/os.js'; const props = defineProps<{ items: MenuItem[]; - ev: MouseEvent; + ev: PointerEvent; }>(); const emit = defineEmits<{ @@ -75,8 +75,8 @@ onBeforeUnmount(() => { window.document.body.removeEventListener('mousedown', onMousedown); }); -function onMousedown(evt: Event) { - if (!contains(rootEl.value, evt.target) && (rootEl.value !== evt.target)) emit('closed'); +function onMousedown(evt: MouseEvent) { + if (!elementContains(rootEl.value, evt.target as Element) && (rootEl.value !== evt.target)) emit('closed'); } </script> diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 6c07eac47a..1fad936d16 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkModalWindow> </template> -<script lang="ts" setup> +<script lang="ts" setup generic="F extends File | Blob"> import { onMounted, useTemplateRef, ref, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; @@ -38,13 +38,13 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - imageFile: File | Blob; + imageFile: F; aspectRatio: number | null; uploadFolder?: string | null; }>(); const emit = defineEmits<{ - (ev: 'ok', cropped: File | Blob): void; + (ev: 'ok', cropped: F): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); @@ -74,8 +74,14 @@ async function ok() { }); const f = await promise; + let finalFile: F; + if (props.imageFile instanceof File) { + finalFile = new File([f], props.imageFile.name, { type: f.type }) as F; + } else { + finalFile = f as F; + } - emit('ok', f); + emit('ok', finalFile); if (dialogEl.value != null) dialogEl.value.close(); } diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 705301a6a6..fb8b38de6d 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template> <template #caption> - <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"/> - <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> + <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"></span> + <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"></span> </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect> @@ -41,13 +41,19 @@ SPDX-License-Identifier: AGPL-3.0-only </MkModal> </template> +<script lang="ts"> +export type Result = string | number | true | null; +export type MkDialogReturnType<T = Result> = { canceled: true, result: undefined } | { canceled: false, result: T }; +</script> + <script lang="ts" setup> import { ref, useTemplateRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { OptionValue } from '@/types/option-value.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; @@ -65,8 +71,6 @@ type Select = { default: OptionValue | null; }; -type Result = string | number | true | null; - const props = withDefaults(defineProps<{ type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; title?: string; @@ -93,7 +97,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void; + (ev: 'done', v: MkDialogReturnType): void; (ev: 'closed'): void; }>(); @@ -131,7 +135,7 @@ function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare - emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result }); + emit('done', { canceled, result } as MkDialogReturnType); modal.value?.close(); } diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue index f72f091383..808a9ae2f8 100644 --- a/packages/frontend/src/components/MkDivider.vue +++ b/packages/frontend/src/components/MkDivider.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only borderWidth ? { borderWidth: borderWidth } : {}, borderColor ? { borderColor: borderColor } : {}, ]" -/> +></div> </template> <script setup lang="ts"> diff --git a/packages/frontend/src/components/MkDraggable.vue b/packages/frontend/src/components/MkDraggable.vue new file mode 100644 index 0000000000..6e2e038f87 --- /dev/null +++ b/packages/frontend/src/components/MkDraggable.vue @@ -0,0 +1,311 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup + tag="div" + :enterActiveClass="$style.transition_items_enterActive" + :leaveActiveClass="$style.transition_items_leaveActive" + :enterFromClass="$style.transition_items_enterFrom" + :leaveToClass="$style.transition_items_leaveTo" + :moveClass="$style.transition_items_move" + :class="[$style.items, { [$style.dragging]: dragging, [$style.horizontal]: direction === 'horizontal', [$style.vertical]: direction === 'vertical', [$style.withGaps]: withGaps, [$style.canNest]: canNest }]" +> + <slot name="header"></slot> + <div + v-if="modelValue.length === 0" + :class="$style.emptyDropArea" + @dragover.prevent.stop="() => {}" + @dragleave="() => {}" + @drop.prevent.stop="onEmptyDrop($event)" + > + </div> + <div + v-for="(item, i) in modelValue" + :key="`MkDraggableRoot:${item.id}`" + :class="$style.item" + :draggable="!manualDragStart" + @dragstart.stop="onDragstart($event, item)" + > + <div + :class="[$style.forwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'forward' }]" + @dragover.prevent.stop="onDragover($event, item, false)" + @dragleave="onDragleave($event, item)" + @drop.prevent.stop="onDrop($event, item, false)" + ></div> + <div :key="`MkDraggableItem:${item.id}`" style="position: relative; z-index: 0;"> + <slot :item="item" :index="i" :dragStart="(ev) => onDragstart(ev, item)"></slot> + </div> + <div + :class="[$style.backwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'backward' }]" + @dragover.prevent.stop="onDragover($event, item, true)" + @dragleave="onDragleave($event, item)" + @drop.prevent.stop="onDrop($event, item, true)" + ></div> + </div> + <slot name="footer"></slot> +</TransitionGroup> +</template> + +<script lang="ts"> +import { ref } from 'vue'; + +// 別々のコンポーネントインスタンス間でD&Dを融通するためにグローバルに状態を持っておく必要がある +const dragging = ref(false); +let dropCallback: ((targetInstanceId: string) => void) | null = null; +</script> + +<script lang="ts" setup generic="T extends { id: string; }"> +import { nextTick } from 'vue'; +import { getDragData, setDragData } from '@/drag-and-drop.js'; +import { genId } from '@/utility/id.js'; + +const slots = defineSlots<{ + default(props: { item: T; index: number; dragStart: (ev: DragEvent) => void }): any; + header(): any; + footer(): any; +}>(); + +const props = withDefaults(defineProps<{ + modelValue: T[]; + direction: 'horizontal' | 'vertical'; + group?: string | null; + manualDragStart?: boolean; + withGaps?: boolean; + canNest?: boolean; +}>(), { + group: null, + manualDragStart: false, + withGaps: false, + canNest: false, +}); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: T[]): void; +}>(); + +const dropReadyArea = ref<[T['id'] | null, 'forward' | 'backward' | null]>([null, null]); +const instanceId = genId(); +const group = props.group ?? instanceId; + +function onDragstart(ev: DragEvent, item: T) { + if (ev.dataTransfer == null) return; + ev.dataTransfer.effectAllowed = 'move'; + setDragData(ev, 'MkDraggable', { item, instanceId, group }); + + const target = ev.target as HTMLElement; + target.addEventListener('dragend', (ev) => { + dragging.value = false; + dropReadyArea.value = [null, null]; + }, { once: true }); + + dropCallback = (targetInstanceId) => { + if (targetInstanceId === instanceId) return; + const newValue = props.modelValue.filter(x => x.id !== item.id); + emit('update:modelValue', newValue); + }; + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + // SEE: https://issues.chromium.org/issues/41150279 + window.setTimeout(() => { + dragging.value = true; + }, 10); +} + +function onDragover(ev: DragEvent, item: T, backward: boolean) { + nextTick(() => { + dropReadyArea.value = [item.id, backward ? 'backward' : 'forward']; + }); +} + +function onDragleave(ev: DragEvent, item: T) { + dropReadyArea.value = [null, null]; +} + +function onDrop(ev: DragEvent, item: T, backward: boolean) { + const dragged = getDragData(ev, 'MkDraggable'); + dropReadyArea.value = [null, null]; + if (dragged == null || dragged.group !== group || dragged.item.id === item.id) return; + dropCallback?.(instanceId); + + const fromIndex = props.modelValue.findIndex(x => x.id === dragged.item.id); + let toIndex = props.modelValue.findIndex(x => x.id === item.id); + + const newValue = [...props.modelValue]; + if (fromIndex > -1) newValue.splice(fromIndex, 1); + toIndex = newValue.findIndex(x => x.id === item.id); + if (backward) toIndex += 1; + newValue.splice(toIndex, 0, dragged.item as T); + + emit('update:modelValue', newValue); +} + +function onEmptyDrop(ev: DragEvent) { + const dragged = getDragData(ev, 'MkDraggable'); + if (dragged == null) return; + dropCallback?.(instanceId); + + emit('update:modelValue', [dragged.item as T]); +} +</script> + +<style lang="scss" module> +.transition_items_move, +.transition_items_enterActive, +.transition_items_leaveActive { + transition: all 0.15s ease; +} +.transition_items_enterFrom, +.transition_items_leaveTo { + opacity: 0; +} +.transition_items_leaveActive { + position: absolute; +} + +.items { + display: flex; + align-items: center; + justify-content: left; + flex-wrap: wrap; +} + +.items.horizontal { + flex-direction: row; +} +.items.vertical { + flex-direction: column; +} + +.item { + position: relative; +} + +.items.vertical .item { + width: 100%; +} + +.items.horizontal.withGaps { + row-gap: var(--MI-margin); +} + +.items.horizontal.withGaps .item { + padding-left: calc(var(--MI-margin) / 2); + padding-right: calc(var(--MI-margin) / 2); +} + +.items.vertical.withGaps .item { + padding-top: calc(var(--MI-margin) / 2); + padding-bottom: calc(var(--MI-margin) / 2); +} + +.forwardArea, .backwardArea { + position: absolute; + z-index: 1; + pointer-events: none; +} + +.items.dragging { + .forwardArea, .backwardArea { + pointer-events: auto; + } +} + +.items.horizontal { + .forwardArea { + top: 0; + left: 0; + width: 50%; + height: 100%; + } + + .backwardArea { + top: 0; + right: 0; + width: 50%; + height: 100%; + } +} + +.items.vertical { + .forwardArea { + top: 0; + left: 0; + width: 100%; + height: 50%; + } + + .backwardArea { + bottom: 0; + left: 0; + width: 100%; + height: 50%; + } +} + +.items.canNest.horizontal { + .forwardArea, .backwardArea { + width: 30px; + } +} + +.items.canNest.vertical { + .forwardArea, .backwardArea { + height: 30px; + } +} + +.dropReady::before { + content: ''; + position: absolute; + z-index: 99999; + background: var(--MI_THEME-accent); + border-radius: 999px; + pointer-events: none; +} + +.items.horizontal { + .forwardArea.dropReady::before { + top: 0; + left: -1px; + width: 2px; + height: 100%; + } + + .backwardArea.dropReady::before { + top: 0; + right: -1px; + width: 2px; + height: 100%; + } +} + +.items.vertical { + .forwardArea.dropReady::before { + top: -1px; + left: 0; + width: 100%; + height: 2px; + } + + .backwardArea.dropReady::before { + bottom: -1px; + left: 0; + width: 100%; + height: 2px; + } +} + +.items.horizontal .emptyDropArea { + width: 40px; + height: 40px; +} + +.items.vertical .emptyDropArea { + width: 100%; + height: 50px; +} +</style> diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 0eca85b3a6..e2858084c0 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -64,7 +64,7 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index d7dd12408c..6d93dfc0d4 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -57,7 +57,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void; - (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder); + (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder): void; (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -231,17 +231,17 @@ function rename() { } function move() { - selectDriveFolder(null).then(folder => { - if (folder[0] && folder[0].id === props.folder.id) return; + selectDriveFolder(null).then(({ canceled, folders }) => { + if (canceled || (folders[0] && folders[0].id === props.folder.id)) return; misskeyApi('drive/folders/update', { folderId: props.folder.id, - parentId: folder[0] ? folder[0].id : null, + parentId: folders[0] ? folders[0].id : null, }).then(() => { globalEvents.emit('driveFoldersUpdated', [{ ...props.folder, - parentId: folder[0] ? folder[0].id : null, - parent: folder[0] ?? null, + parentId: folders[0] ? folders[0].id : null, + parent: folders[0] ?? null, }]); }); }); @@ -277,7 +277,7 @@ function setAsUploadFolder() { prefer.commit('uploadFolder', props.folder.id); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { let menu: MenuItem[]; menu = [{ text: i18n.ts.openInWindow, diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index d8c949d8eb..2961bc5032 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -69,7 +69,6 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="(f, i) in foldersPaginator.items.value" :key="f.id" v-anim="i" - :class="$style.folder" :folder="f" :selectMode="select === 'folder'" :isSelected="selectedFolders.some(x => x.id === f.id)" @@ -102,7 +101,6 @@ SPDX-License-Identifier: AGPL-3.0-only > <XFile v-for="file in item.items" :key="file.id" - :class="$style.file" :file="file" :folder="folder" :isSelected="selectedFiles.some(x => x.id === file.id)" @@ -125,7 +123,6 @@ SPDX-License-Identifier: AGPL-3.0-only > <XFile v-for="file in filesPaginator.items.value" :key="file.id" - :class="$style.file" :file="file" :folder="folder" :isSelected="selectedFiles.some(x => x.id === file.id)" @@ -135,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only /> </TransitionGroup> - <MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> + <MkButton + v-show="canFetchFiles" + v-appear="shouldEnableInfiniteScroll ? fetchMoreFiles : null" + :class="$style.loadMore" + primary + rounded + @click="fetchMoreFiles" + > + {{ i18n.ts.loadMore }} + </MkButton> <div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty"> <div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div> @@ -182,10 +188,12 @@ const props = withDefaults(defineProps<{ type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; + forceDisableInfiniteScroll?: boolean; }>(), { initialFolder: null, multiple: false, select: null, + forceDisableInfiniteScroll: false, }); const emit = defineEmits<{ @@ -194,6 +202,10 @@ const emit = defineEmits<{ (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; }>(); +const shouldEnableInfiniteScroll = computed(() => { + return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll; +}); + const folder = ref<Misskey.entities.DriveFolder | null>(null); const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); @@ -228,10 +240,9 @@ const filesPaginator = markRaw(new Paginator('drive/files', { params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない folderId: folder.value ? folder.value.id : null, type: props.type, - sort: sortModeSelect.value, + sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value, }), })); - const foldersPaginator = markRaw(new Paginator('drive/folders', { limit: 30, canFetchDetection: 'limit', @@ -240,6 +251,16 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', { }), })); +const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value)); + +async function fetchMoreFiles() { + if (filesPaginator.order.value === 'oldest') { + filesPaginator.fetchNewer(); + } else { + filesPaginator.fetchOlder(); + } +} + const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month'); const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value)); @@ -250,10 +271,10 @@ watch(sortModeSelect, () => { async function initialize() { fetching.value = true; - await Promise.all([ - foldersPaginator.init(), - filesPaginator.init(), - ]); + await foldersPaginator.reload(); + filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older'; + filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest'; + await filesPaginator.reload(); fetching.value = false; } @@ -472,7 +493,7 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -function onFileClick(ev: MouseEvent, file: Misskey.entities.DriveFile) { +function onFileClick(ev: PointerEvent, file: Misskey.entities.DriveFile) { if (ev.shiftKey) { isEditMode.value = true; } @@ -544,7 +565,7 @@ function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder folder.value = folderToMove; hierarchyFolders.value = []; - const dive = folderToDive => { + const dive = (folderToDive: Misskey.entities.DriveFolder) => { hierarchyFolders.value.unshift(folderToDive); if (folderToDive.parent) dive(folderToDive.parent); }; @@ -558,17 +579,19 @@ function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder async function moveFilesBulk() { if (selectedFiles.value.length === 0) return; - const toFolder = await selectDriveFolder(folder.value ? folder.value.id : null); + const { canceled, folders } = await selectDriveFolder(folder.value ? folder.value.id : null); + + if (canceled) return; await os.apiWithDialog('drive/files/move-bulk', { fileIds: selectedFiles.value.map(f => f.id), - folderId: toFolder[0] ? toFolder[0].id : null, + folderId: folders[0] ? folders[0].id : null, }); globalEvents.emit('driveFilesUpdated', selectedFiles.value.map(x => ({ ...x, - folderId: toFolder[0] ? toFolder[0].id : null, - folder: toFolder[0] ?? null, + folderId: folders[0] ? folders[0].id : null, + folder: folders[0] ?? null, }))); } @@ -668,11 +691,11 @@ function getMenu() { return menu; } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getMenu(), ev); } diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 4f16149caa..9002669378 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only :enterFromClass="$style.transition_x_enterFrom" :leaveToClass="$style.transition_x_leaveTo" > - <div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot"> - <div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]"> - <MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/> + <MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading"> + <template #preview> <div :class="$style.embedCodeGenPreviewWrapper"> <div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div> <div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert> @@ -43,27 +42,29 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - </div> - <div :class="$style.embedCodeGenSettings" class="_gaps"> - <MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0"> - <template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template> - <template #suffix>px</template> - <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> - </MkInput> - <MkSelect v-model="colorMode" :items="colorModeDef"> - <template #label>{{ i18n.ts.theme }}</template> - </MkSelect> - <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> - <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> - <MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch> - <MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo> - <MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo> - <div class="_buttons"> - <MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton> - <MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton> + </template> + <template #controls> + <div class="_spacer _gaps"> + <MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0"> + <template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template> + <template #suffix>px</template> + <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> + </MkInput> + <MkSelect v-model="colorMode" :items="colorModeDef"> + <template #label>{{ i18n.ts.theme }}</template> + </MkSelect> + <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> + <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> + <MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch> + <MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo> + <MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo> + <div class="_buttons"> + <MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton> + <MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton> + </div> </div> - </div> - </div> + </template> + </MkPreviewWithControls> <div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot"> <div :class="$style.embedCodeGenResultWrapper" class="_gaps"> <div class="_gaps_s"> @@ -89,18 +90,17 @@ import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; -import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; -import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -302,29 +302,6 @@ onUnmounted(() => { height: 100%; } -.embedCodeGenInputRoot { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.embedCodeGenPreviewRoot { - position: relative; - cursor: not-allowed; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .embedCodeGenPreviewWrapper { display: flex; flex-direction: column; @@ -372,11 +349,6 @@ onUnmounted(() => { color-scheme: light dark; } -.embedCodeGenSettings { - padding: 24px; - overflow-y: scroll; -} - .embedCodeGenResultRoot { box-sizing: border-box; padding: 24px; @@ -417,11 +389,4 @@ onUnmounted(() => { .embedCodeGenResultButtons { margin: 0 auto; } - -@container (max-width: 800px) { - .embedCodeGenInputRoot { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index ef515e471f..3ee32710e5 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; -import type { Ref } from 'vue'; import { getEmojiName } from '@@/js/emojilist.js'; +import type { Ref } from 'vue'; import type { CustomEmojiFolderTree } from '@@/js/emojilist.js'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; @@ -78,7 +78,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'chosen', v: string, event: MouseEvent): void; + (ev: 'chosen', v: string, event: PointerEvent): void; }>(); const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props.emojis.value); @@ -86,13 +86,13 @@ const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props const shown = ref(!!props.initialShown); /** @see MkEmojiPicker.vue */ -function computeButtonTitle(ev: MouseEvent): void { +function computeButtonTitle(ev: PointerEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; elm.title = getEmojiName(emoji); } -function nestedChosen(emoji: string, ev: MouseEvent) { +function nestedChosen(emoji: string, ev: PointerEvent) { emit('chosen', emoji, ev); } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 33e9137c2f..bf0f9d0130 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -412,13 +412,13 @@ function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeE } /** @see MkEmojiPicker.section.vue */ -function computeButtonTitle(ev: MouseEvent): void { +function computeButtonTitle(ev: PointerEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; elm.title = getEmojiName(emoji); } -function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { +function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: PointerEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index c9d18ee731..3f7eb9bccd 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #key>{{ i18n.ts.permission }}</template> <template #value> <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> - <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] ?? permission }}</li> </ul> <template v-else>{{ i18n.ts.none }}</template> </template> @@ -91,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> - <slot name="additionalInfo"/> + <slot name="additionalInfo"></slot> <div class="_buttonsCenter"> <MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> @@ -101,6 +101,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> +import * as Misskey from 'misskey-js'; + export type Extension = { type: 'plugin'; raw: string; @@ -109,7 +111,7 @@ export type Extension = { version: string; author: string; description?: string; - permissions?: string[]; + permissions?: (typeof Misskey.permissions)[number][]; config?: Record<string, unknown>; }; } | { @@ -125,7 +127,6 @@ export type Extension = { <script lang="ts" setup> import { computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index a998c810f0..59dac46162 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <MkPagination v-slot="{ items }" :paginator="paginator"> - <div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]"> + <div + :class="{ + [$style.grid]: viewMode === 'grid', + [$style.list]: viewMode === 'list', + '_gaps_s': viewMode === 'list', + }" + > <MkA v-for="file in items" :key="file.id" diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 94fdf6da36..864f53d09c 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -169,7 +169,7 @@ function afterLeave(el: Element) { let pageId = pageFolderTeleportCount.value; pageFolderTeleportCount.value += 1000; -async function toggle(ev: MouseEvent) { +async function toggle(ev: PointerEvent) { if (asPage && !opened.value) { pageId++; const { dispose } = await popup(MkFolderPage, { diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ba21fe82e4..72a24411c1 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -81,7 +81,13 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { } async function onClick() { - pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } }); + const isLoggedIn = await pleaseLogin({ + openOnRemote: { + type: 'web', + path: `/@${props.user.username}@${props.user.host ?? host}`, + }, + }); + if (!isLoggedIn) return; wait.value = true; diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkForm.file.vue index 182ff3ccf5..d233467e8b 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkForm.file.vue @@ -50,7 +50,7 @@ if (props.fileId) { }); } -function selectButton(ev: MouseEvent) { +function selectButton(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/components/MkForm.vue b/packages/frontend/src/components/MkForm.vue new file mode 100644 index 0000000000..f2360e8cdd --- /dev/null +++ b/packages/frontend/src/components/MkForm.vue @@ -0,0 +1,125 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="Object.values(form).filter(item => typeof item.hidden !== 'boolean' || item.hidden === true).length > 0" class="_gaps_m"> + <template v-for="v, k in form"> + <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> + <MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkInput> + <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkInput> + <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkTextarea> + <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]"> + <span v-text="v.label || k"></span> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkSwitch> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + </MkSelect> + <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]" :options="getRadioOptionsDef(v)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + </MkRadios> + <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkRange> + <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> + <span v-text="v.content || k"></span> + </MkButton> + <XFile + v-else-if="v.type === 'drive-file'" + :fileId="v.defaultFileId" + :validate="async f => !v.validate || await v.validate(f)" + @update="f => values[k] = f" + /> + </template> +</div> +<MkResult v-else type="empty" :text="i18n.ts.nothingToConfigure"/> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import XFile from '@/components/MkForm.file.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkRange from '@/components/MkRange.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import { i18n } from '@/i18n.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { MkRadiosOption } from '@/components/MkRadios.vue'; +import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; + +const props = defineProps<{ + form: Form; +}>(); + +const emit = defineEmits<{ + (ev: 'canSaveStateChange', canSave: boolean): void; +}>(); + +// TODO: ジェネリックにしたい +const values = defineModel<Record<string, any>>({ required: true }); + +// 保存可能状態の管理 +const inputSavingStates = ref<Record<string, { changed: boolean; invalid: boolean }>>({}); + +function onSavingStateChange(key: string, changed: boolean, invalid: boolean) { + inputSavingStates.value[key] = { changed, invalid }; +} + +const canSave = computed(() => { + for (const key in inputSavingStates.value) { + const state = inputSavingStates.value[key]; + if ( + ('manualSave' in props.form[key] && props.form[key].manualSave && state.changed) || + state.invalid + ) { + return false; + } + if ('required' in props.form[key] && props.form[key].required) { + const val = values.value[key]; + if (val === null || val === undefined || val === '') { + return false; + } + } + } + return true; +}); + +watch(canSave, (newCanSave) => { + emit('canSaveStateChange', newCanSave); +}, { immediate: true }); + +function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { + return def.enum.map((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); +} + +function getRadioOptionsDef(def: RadioFormItem): MkRadiosOption[] { + return def.options.map<MkRadiosOption>((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); +} +</script> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 142ccb12a3..091721b40b 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only :width="450" :canClose="false" :withOkButton="true" - :okButtonDisabled="false" + :okButtonDisabled="!canSave" @click="cancel()" @ok="ok()" @close="cancel()" @@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;"> - <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> - <template v-for="(v, k) in Object.fromEntries(Object.entries(form))"> - <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> - <MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkInput> - <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkInput> - <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkTextarea> - <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]"> - <span v-text="v.label || k"></span> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkSwitch> - <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - </MkSelect> - <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option> - </MkRadios> - <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkRange> - <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> - <span v-text="v.content || k"></span> - </MkButton> - <XFile - v-else-if="v.type === 'drive-file'" - :fileId="v.defaultFileId" - :validate="async f => !v.validate || await v.validate(f)" - @update="f => values[k] = f" - /> - </template> - </div> - <MkResult v-else type="empty"/> + <MkForm v-model="values" :form="form" @canSaveStateChange="onCanSaveStateChanged"/> </div> </MkModalWindow> </template> <script lang="ts" setup> -import { reactive, useTemplateRef } from 'vue'; -import MkInput from './MkInput.vue'; -import MkTextarea from './MkTextarea.vue'; -import MkSwitch from './MkSwitch.vue'; -import MkSelect from './MkSelect.vue'; -import MkRange from './MkRange.vue'; -import MkButton from './MkButton.vue'; -import MkRadios from './MkRadios.vue'; -import XFile from './MkFormDialog.file.vue'; -import type { MkSelectItem } from '@/components/MkSelect.vue'; -import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; +import { ref, useTemplateRef } from 'vue'; +import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { i18n } from '@/i18n.js'; +import MkForm from '@/components/MkForm.vue'; const props = defineProps<{ title: string; @@ -96,19 +46,30 @@ const emit = defineEmits<{ }>(); const dialog = useTemplateRef('dialog'); -const values = reactive({}); -for (const item in props.form) { - if ('default' in props.form[item]) { - values[item] = props.form[item].default ?? null; - } else { - values[item] = null; +const values = ref((() => { + const obj: Record<string, any> = {}; + for (const item in props.form) { + if ('default' in props.form[item]) { + obj[item] = props.form[item].default ?? null; + } else { + obj[item] = null; + } } + return obj; +})()); + +const canSave = ref(true); + +function onCanSaveStateChanged(newCanSave: boolean) { + canSave.value = newCanSave; } function ok() { + if (!canSave.value) return; + emit('done', { - result: values, + result: values.value, }); dialog.value?.close(); } @@ -119,18 +80,4 @@ function cancel() { }); dialog.value?.close(); } - -function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { - return def.enum.map((v) => { - if (typeof v === 'string') { - return { value: v, label: v }; - } else { - return { value: v.value, label: v.label }; - } - }); -} - -function getRadioKey(e: RadioFormItem['options'][number]) { - return typeof e.value === 'string' ? e.value : JSON.stringify(e.value); -} </script> diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index abbf86004b..03780bf3ba 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -125,8 +125,7 @@ async function renderChart() { data: format(values) as any, borderWidth: 0, borderRadius: 3, - backgroundColor(c) { - // @ts-expect-error TS(2339) + backgroundColor(c: any) { const value = c.dataset.data[c.dataIndex].v as number; let a = (value - min) / max; if (value !== 0) { // 0でない限りは完全に不可視にはしない @@ -195,7 +194,7 @@ async function renderChart() { font: { size: 9, }, - callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], + callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value as any], }, }, }, diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue index 3d7801f925..85e86e3a77 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -16,37 +16,36 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template> - <div :class="$style.root"> - <div :class="$style.container"> - <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> - <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas> - <div :class="$style.previewContainer"> - <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> - <div class="_acrylic" :class="$style.editControls"> - <button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button> - </div> - <div class="_acrylic" :class="$style.previewControls"> - <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> - <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> - </div> + <MkPreviewWithControls> + <template #preview> + <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div class="_acrylic" :class="$style.editControls"> + <button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button> + </div> + <div class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> + <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> </div> </div> - <div :class="$style.controls"> - <div class="_spacer _gaps"> - <XLayer - v-for="(layer, i) in layers" - :key="layer.id" - v-model:layer="layers[i]" - @del="onLayerDelete(layer)" - @swapUp="onLayerSwapUp(layer)" - @swapDown="onLayerSwapDown(layer)" - ></XLayer> + </template> - <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> - </div> + <template #controls> + <div class="_spacer _gaps"> + <XLayer + v-for="(layer, i) in layers" + :key="layer.id" + v-model:layer="layers[i]" + @del="onLayerDelete(layer)" + @swapUp="onLayerSwapUp(layer)" + @swapDown="onLayerSwapDown(layer)" + ></XLayer> + + <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> </div> - </div> - </div> + </template> + </MkPreviewWithControls> </MkModalWindow> </template> @@ -56,15 +55,12 @@ import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector. import { i18n } from '@/i18n.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import MkSelect from '@/components/MkSelect.vue'; +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; import XLayer from '@/components/MkImageEffectorDialog.Layer.vue'; import * as os from '@/os.js'; -import { deepClone } from '@/utility/clone.js'; import { FXS } from '@/utility/image-effector/fxs.js'; import { genId } from '@/utility/id.js'; -import { prefer } from '@/preferences.js'; const props = defineProps<{ image: File; @@ -99,7 +95,7 @@ watch(layers, async () => { } }, { deep: true }); -function addEffect(ev: MouseEvent) { +function addEffect(ev: PointerEvent) { os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({ text: fx.uiDefinition.name, action: () => { @@ -223,7 +219,7 @@ watch(enabled, () => { const penMode = ref<'fill' | 'blur' | 'pixelate' | null>(null); -function showPenMenu(ev: MouseEvent) { +function showPenMenu(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts._imageEffector._fxs.fill, action: () => { @@ -299,7 +295,7 @@ function onImagePointerdown(ev: PointerEvent) { scaleX: 0.1, scaleY: 0.1, angle: 0, - radius: 3, + radius: 10, ellipse: false, }, }); @@ -367,33 +363,6 @@ function onImagePointerdown(ev: PointerEvent) { </script> <style module> -.root { - container-type: inline-size; - height: 100%; -} - -.container { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.preview { - position: relative; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .previewContainer { display: flex; flex-direction: column; @@ -442,16 +411,6 @@ function onImagePointerdown(ev: PointerEvent) { } } -.previewSpinner { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - user-select: none; - -webkit-user-drag: none; -} - .previewCanvas { position: absolute; top: 0; @@ -467,15 +426,4 @@ function onImagePointerdown(ev: PointerEvent) { object-fit: contain; touch-action: none; } - -.controls { - overflow-y: scroll; -} - -@container (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkImageEffectorFxForm.vue b/packages/frontend/src/components/MkImageEffectorFxForm.vue index e581b1f743..6bbec6c868 100644 --- a/packages/frontend/src/components/MkImageEffectorFxForm.vue +++ b/packages/frontend/src/components/MkImageEffectorFxForm.vue @@ -28,13 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ v.label ?? k }}</template> <template v-if="v.caption != null" #caption>{{ v.caption }}</template> </MkRange> - <MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]"> + <MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]" :options="v.enum"> <template #label>{{ v.label ?? k }}</template> <template v-if="v.caption != null" #caption>{{ v.caption }}</template> - <option v-for="item in v.enum" :value="item.value"> - <i v-if="item.icon" :class="item.icon"></i> - <template v-else>{{ item.label }}</template> - </option> </MkRadios> <div v-else-if="v.type === 'seed'"> <MkRange v-model="params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1"> @@ -48,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </div> <div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure"> - {{ i18n.ts._imageEffector.nothingToConfigure }} + {{ i18n.ts.nothingToConfigure }} </div> </div> </template> @@ -68,7 +64,7 @@ defineProps<{ const params = defineModel<Record<string, any>>({ required: true }); function getHex(c: ImageEffectorRGB) { - return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; + return `#${c.map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('')}`; } function getRgb(hex: string | number): ImageEffectorRGB | null { diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue index 2a91c85952..1a37a32a96 100644 --- a/packages/frontend/src/components/MkImageFrameEditorDialog.vue +++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue @@ -16,140 +16,139 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template> - <div :class="$style.root"> - <div :class="$style.container"> - <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> - <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> - <div :class="$style.previewContainer"> - <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> - <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> - <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> - </div> + <MkPreviewWithControls> + <template #preview> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> </div> </div> - <div :class="$style.controls"> - <div class="_spacer _gaps"> - <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template> - </MkRange> + </template> - <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }"> - <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template> - </MkInput> + <template #controls> + <div class="_spacer _gaps"> + <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template> + </MkRange> - <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }"> - <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template> - </MkInput> + <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }"> + <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template> + </MkInput> - <MkSelect - v-model="params.font" :items="[ - { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' }, - { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' }, - ]" - > - <template #label>{{ i18n.ts._imageFrameEditor.font }}</template> - </MkSelect> + <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }"> + <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template> + </MkInput> - <MkFolder :defaultOpen="params.labelTop.enabled"> - <template #label>{{ i18n.ts._imageFrameEditor.header }}</template> + <MkSelect + v-model="params.font" :items="[ + { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' }, + { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' }, + ]" + > + <template #label>{{ i18n.ts._imageFrameEditor.font }}</template> + </MkSelect> - <div class="_gaps"> - <MkSwitch v-model="params.labelTop.enabled"> - <template #label>{{ i18n.ts.show }}</template> - </MkSwitch> + <MkFolder :defaultOpen="params.labelTop.enabled"> + <template #label>{{ i18n.ts._imageFrameEditor.header }}</template> - <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> - </MkRange> + <div class="_gaps"> + <MkSwitch v-model="params.labelTop.enabled"> + <template #label>{{ i18n.ts.show }}</template> + </MkSwitch> - <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> - </MkRange> + <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> + </MkRange> - <MkSwitch v-model="params.labelTop.centered"> - <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> - </MkSwitch> + <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> + </MkRange> - <MkInput v-model="params.labelTop.textBig"> - <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> - </MkInput> + <MkSwitch v-model="params.labelTop.centered"> + <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> + </MkSwitch> - <MkTextarea v-model="params.labelTop.textSmall"> - <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> - </MkTextarea> + <MkInput v-model="params.labelTop.textBig"> + <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> + </MkInput> - <MkSwitch v-model="params.labelTop.withQrCode"> - <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> - </MkSwitch> - </div> - </MkFolder> + <MkTextarea v-model="params.labelTop.textSmall"> + <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> + </MkTextarea> - <MkFolder :defaultOpen="params.labelBottom.enabled"> - <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template> + <MkSwitch v-model="params.labelTop.withQrCode"> + <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> + </MkSwitch> + </div> + </MkFolder> - <div class="_gaps"> - <MkSwitch v-model="params.labelBottom.enabled"> - <template #label>{{ i18n.ts.show }}</template> - </MkSwitch> + <MkFolder :defaultOpen="params.labelBottom.enabled"> + <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template> - <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> - </MkRange> + <div class="_gaps"> + <MkSwitch v-model="params.labelBottom.enabled"> + <template #label>{{ i18n.ts.show }}</template> + </MkSwitch> - <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> - </MkRange> + <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> + </MkRange> - <MkSwitch v-model="params.labelBottom.centered"> - <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> - </MkSwitch> + <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> + </MkRange> - <MkInput v-model="params.labelBottom.textBig"> - <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> - </MkInput> + <MkSwitch v-model="params.labelBottom.centered"> + <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> + </MkSwitch> - <MkTextarea v-model="params.labelBottom.textSmall"> - <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> - </MkTextarea> + <MkInput v-model="params.labelBottom.textBig"> + <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> + </MkInput> - <MkSwitch v-model="params.labelBottom.withQrCode"> - <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> - </MkSwitch> - </div> - </MkFolder> + <MkTextarea v-model="params.labelBottom.textSmall"> + <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> + </MkTextarea> - <MkInfo> - <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div> - <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div> - <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div> - <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div> - <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div> - <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div> - <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div> - <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div> - <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div> - <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div> - <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div> - <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div> - <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div> - <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div> - <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div> - <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div> - <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div> - <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div> - <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div> - </MkInfo> - </div> + <MkSwitch v-model="params.labelBottom.withQrCode"> + <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkInfo> + <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div> + <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div> + <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div> + <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div> + <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div> + <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div> + <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div> + <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div> + <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div> + <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div> + <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div> + <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div> + <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div> + <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div> + <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div> + <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div> + <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div> + <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div> + <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div> + </MkInfo> </div> - </div> - </div> + </template> + </MkPreviewWithControls> </MkModalWindow> </template> @@ -157,12 +156,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; import ExifReader from 'exifreader'; import { throttle } from 'throttle-debounce'; +import MkPreviewWithControls from './MkPreviewWithControls.vue'; import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkSelect from '@/components/MkSelect.vue'; -import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRange from '@/components/MkRange.vue'; @@ -173,8 +172,6 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; -import { useMkSelect } from '@/composables/use-mkselect.js'; -import { prefer } from '@/preferences.js'; const $i = ensureSignin(); @@ -393,7 +390,7 @@ async function save() { } function getHex(c: [number, number, number]) { - return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; + return `#${c.map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('')}`; } function getRgb(hex: string | number): [number, number, number] | null { @@ -412,33 +409,6 @@ function getRgb(hex: string | number): [number, number, number] | null { </script> <style module> -.root { - container-type: inline-size; - height: 100%; -} - -.container { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.preview { - position: relative; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .previewContainer { display: flex; flex-direction: column; @@ -495,15 +465,4 @@ function getRgb(hex: string | number): [number, number, number] | null { box-sizing: border-box; object-fit: contain; } - -.controls { - overflow-y: scroll; -} - -@container (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue index e17a1651cf..530b6c45db 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.vue +++ b/packages/frontend/src/components/MkImgPreviewDialog.vue @@ -11,10 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only @close="close" @esc="close" @click="close" + @closed="emit('closed')" > <template #header>{{ file.name }}</template> <div :class="$style.container"> - <img :src="file.url" :alt="file.comment ?? file.name" :class="$style.img"/> + <img :src="file.url" :alt="file.comment || file.name" :class="$style.img"/> </div> </MkModalWindow> </template> @@ -27,6 +28,10 @@ defineProps<{ file: Misskey.entities.DriveFile; }>(); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const modal = ref<typeof MkModalWindow | null>(null); function close() { diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 983a0932c3..a61836e101 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only draggable="false" tabindex="-1" style="-webkit-user-drag: none;" - /> + ></canvas> <img v-show="!hide" key="img" diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 7f052dff94..aebeefe165 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only @input="onInput" > <datalist v-if="datalist" :id="id"> - <option v-for="data in datalist" :key="data" :value="data"/> + <option v-for="data in datalist" :key="data" :value="data"></option> </datalist> <div ref="suffixEl" :class="$style.suffix"><slot name="suffix"></slot></div> </div> @@ -88,10 +88,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'change', _ev: InputEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter', _ev: KeyboardEvent): void; (ev: 'update:modelValue', value: ModelValueType<T>): void; + (ev: 'savingStateChange', saved: boolean, invalid: boolean): void; }>(); const { modelValue } = toRefs(props); @@ -111,10 +112,9 @@ const height = let autocompleteWorker: Autocomplete | null = null; const focus = () => inputEl.value?.focus(); -const onInput = (event: Event) => { - const ev = event as KeyboardEvent; +const onInput = (event: InputEvent) => { changed.value = true; - emit('change', ev); + emit('change', event); }; const onKeydown = (ev: KeyboardEvent) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; @@ -153,6 +153,10 @@ watch(v, () => { invalid.value = inputEl.value?.validity.badInput ?? true; }); +watch([changed, invalid], ([newChanged, newInvalid]) => { + emit('savingStateChange', newChanged, newInvalid); +}, { immediate: true }); + // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index 7902151921..130a0e9986 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -33,7 +33,7 @@ misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, spa chartValues.value = res.requests.received; }); -function getInstanceIcon(instance): string { +function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; } </script> diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 13048a2e1b..368fa5be27 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -57,10 +57,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import MkSelect from '@/components/MkSelect.vue'; import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue'; -import MkChart from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkChart from '@/components/MkChart.vue'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { $i } from '@/i.js'; import * as os from '@/os.js'; @@ -172,7 +172,14 @@ const { handler: externalTooltipHandler2 } = useChartTooltip({ position: 'middle', }); -function createDoughnut(chartEl, tooltip, data) { +type ChartData = { + name: string, + color: string, + value: number, + onClick?: () => void, +}[]; + +function createDoughnut(chartEl: HTMLCanvasElement, tooltip: ReturnType<typeof useChartTooltip>['handler'], data: ChartData) { const chartInstance = new Chart(chartEl, { type: 'doughnut', data: { @@ -198,8 +205,8 @@ function createDoughnut(chartEl, tooltip, data) { onClick: (ev) => { if (ev.native == null) return; const hit = chartInstance.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; - if (hit && data[hit.index].onClick) { - data[hit.index].onClick(); + if (hit != null) { + data[hit.index].onClick?.(); } }, plugins: { @@ -223,16 +230,9 @@ function createDoughnut(chartEl, tooltip, data) { onMounted(() => { misskeyApiGet('federation/stats', { limit: 30 }).then(fedStats => { - type ChartData = { - name: string, - color: string | null, - value: number, - onClick?: () => void, - }[]; - const subs: ChartData = fedStats.topSubInstances.map(x => ({ name: x.host, - color: x.themeColor, + color: x.themeColor ?? '#888888', value: x.followersCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); @@ -245,11 +245,11 @@ onMounted(() => { value: fedStats.otherFollowersCount, }); - createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); + if (subDoughnutEl.value != null) createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); const pubs: ChartData = fedStats.topPubInstances.map(x => ({ name: x.host, - color: x.themeColor, + color: x.themeColor ?? '#888888', value: x.followingCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); @@ -262,7 +262,7 @@ onMounted(() => { value: fedStats.otherFollowingCount, }); - createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); + if (pubDoughnutEl.value != null) createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); }); }); </script> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index d8725ade0b..0c73df4e2d 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> - <button v-if="item.action" v-click-anime class="_button item" @click="$event => { item.action($event); close(); }"> + <button v-if="item.action != null" v-click-anime class="_button item" @click="$event => { item.action!($event); close(); }"> <i class="icon" :class="item.icon"></i> <div class="text">{{ item.text }}</div> <span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span> <span v-else-if="item.indicate" class="indicator _blink"><i class="_indicatorCircle"></i></span> </button> - <MkA v-else v-click-anime :to="item.to" class="item" @click.passive="close()"> + <MkA v-else-if="item.to != null" v-click-anime :to="item.to" class="item" @click.passive="close()"> <i class="icon" :class="item.icon"></i> <div class="text">{{ item.text }}</div> <span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span> diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index e3bb39549f..efcbf26a29 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; @@ -154,16 +155,11 @@ function hasFocus() { const playerEl = useTemplateRef('playerEl'); const audioEl = useTemplateRef('audioEl'); -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); +const hide = ref(shouldHideFileByDefault(props.audio)); async function reveal() { - if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.audio))) { + return; } hide.value = false; diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 7730e01a9f..fd86b61b87 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> - <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="reveal"> + <div v-else-if="hide" :class="$style.sensitive" @click="reveal"> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> @@ -27,23 +27,18 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; import MkMediaAudio from '@/components/MkMediaAudio.vue'; -import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; const props = defineProps<{ media: Misskey.entities.DriveFile; }>(); -const hide = ref(true); +const hide = ref(shouldHideFileByDefault(props.media)); async function reveal() { - if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.media))) { + return; } hide.value = false; diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index f59d15d9a2..4236bd943a 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -77,6 +77,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; @@ -99,19 +100,15 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages) : props.image.thumbnailUrl!, ); -async function reveal(ev: MouseEvent) { +async function reveal(ev: PointerEvent) { if (!props.controls) { return; } if (hide.value) { ev.stopPropagation(); - if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.image))) { + return; } hide.value = false; @@ -119,14 +116,14 @@ async function reveal(ev: MouseEvent) { } // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする -watch(() => props.image, () => { - hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore'); +watch(() => props.image, (newImage) => { + hide.value = shouldHideFileByDefault(newImage); }, { deep: true, immediate: true, }); -function showMenu(ev: MouseEvent) { +function getMenu() { const menuItems: MenuItem[] = []; menuItems.push({ @@ -191,9 +188,16 @@ function showMenu(ev: MouseEvent) { }); } - os.popupMenu(menuItems, ev.currentTarget ?? ev.target); + return menuItems; } +function showMenu(ev: PointerEvent) { + os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); +} + +function onContextmenu(ev: PointerEvent) { + os.contextMenu(getMenu(), ev); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index bfc8179e13..9090e74bb6 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div :class="$style.root"> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> <div ref="gallery" :class="[ $style.medias, + ...(prefer.s.showMediaListByGridInWideArea ? [$style.gridInWideArea] : []), count === 1 ? [$style.n1, { [$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9', [$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1', @@ -107,8 +108,10 @@ onMounted(() => { src: media.url, w: media.properties.width, h: media.properties.height, - alt: media.comment ?? media.name, - comment: media.comment ?? media.name, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + alt: media.comment || media.name, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + comment: media.comment || media.name, }; if (media.properties.orientation != null && media.properties.orientation >= 5) { [item.w, item.h] = [item.h, item.w]; @@ -155,8 +158,10 @@ onMounted(() => { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } itemData.msrc = file.thumbnailUrl ?? undefined; - itemData.alt = file.comment ?? file.name; - itemData.comment = file.comment ?? file.name; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + itemData.alt = file.comment || file.name; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + itemData.comment = file.comment || file.name; itemData.thumbCropped = true; return itemData; @@ -226,6 +231,10 @@ defineExpose({ </script> <style lang="scss" module> +.root { + container-type: inline-size; +} + .container { position: relative; width: 100%; @@ -309,6 +318,20 @@ defineExpose({ border-radius: 8px; } +@container (min-width: 500px) { + .medias.gridInWideArea { + display: grid; + aspect-ratio: auto; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: auto; + grid-gap: 8px; + + > .media { + aspect-ratio: 1 / 1; + } + } +} + :global(.pswp) { --pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important; --pswp-bg: var(--MI_THEME-modalBg) !important; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index b0f7a909d3..4d06e42c05 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -124,6 +124,7 @@ import hasAudio from '@/utility/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; @@ -176,15 +177,11 @@ function hasFocus() { } // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); +const hide = ref(shouldHideFileByDefault(props.video)); async function reveal() { - if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.video))) { + return; } hide.value = false; @@ -193,7 +190,7 @@ async function reveal() { // Menu const menuShowing = ref(false); -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { const menu: MenuItem[] = [ // TODO: 再生キューに追加 { @@ -708,7 +705,7 @@ onDeactivated(() => { .controlButton { padding: 6px; border-radius: calc(var(--MI-radius) / 2); - transition: background-color .2s ease-in-out; + transition: background-color .15s ease; font-size: 1.05rem; &:hover { @@ -763,4 +760,21 @@ onDeactivated(() => { } } } + +@container (max-width: 300px) { + .videoControls { + grid-template-areas: + "left . right" + "seekbar seekbar seekbar"; + grid-template-columns: auto 1fr auto; + } + + .controlsTime { + display: none; + } + + .controlsVolume { + display: none; + } +} </style> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 6c8fac934c..b618dab6b2 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span><MkEllipsis/></span> </span> - <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]"> + <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1"> <component :is="item.component" v-bind="item.props"/> </div> @@ -316,16 +316,27 @@ function onItemMouseLeave() { if (childCloseTimer) window.clearTimeout(childCloseTimer); } -async function showRadioOptions(item: MenuRadio, ev: Event) { +async function showRadioOptions(item: MenuRadio, ev: MouseEvent | PointerEvent | KeyboardEvent) { const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => { const value = item.options[key]; return { type: 'radioOption', text: key, action: () => { - item.ref = value; + if ('value' in item.ref) { + item.ref.value = value; + } else { + // @ts-expect-error リアクティビティは保たれる + item.ref = value; + } }, - active: computed(() => item.ref === value), + active: computed(() => { + if ('value' in item.ref) { + return item.ref.value === value; + } else { + return item.ref === value; + } + }), }; }); @@ -341,7 +352,7 @@ async function showRadioOptions(item: MenuRadio, ev: Event) { } } -async function showChildren(item: MenuParent, ev: Event) { +async function showChildren(item: MenuParent, ev: MouseEvent | PointerEvent | KeyboardEvent) { ev.stopPropagation(); const children: MenuItem[] = await (async () => { @@ -371,7 +382,7 @@ async function showChildren(item: MenuParent, ev: Event) { } } -function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) { +function clicked(fn: MenuAction, ev: PointerEvent, doClose = true) { fn(ev); if (!doClose) return; diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 660d5a26be..92174d8ef7 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -184,8 +184,8 @@ const align = () => { const width = content.value!.offsetWidth; const height = content.value!.offsetHeight; - let left; - let top; + let left = 0; + let top = 0; const x = anchorRect.left + (fixed.value ? 0 : window.scrollX); const y = anchorRect.top + (fixed.value ? 0 : window.scrollY); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index a7299d2961..c78cc44425 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -468,8 +468,12 @@ if (!props.mock) { } } -function renote() { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function renote() { + if (props.mock) return; + + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); @@ -478,11 +482,12 @@ function renote() { subscribeManuallyToNoteCapture(); } -function reply(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); - if (props.mock) { - return; - } +async function reply() { + if (props.mock) return; + + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + os.post({ reply: appearNote, channel: appearNote.channel, @@ -491,8 +496,10 @@ function reply(): void { }); } -function react(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function react() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -587,7 +594,7 @@ function toggleReact() { } } -function onContextmenu(ev: MouseEvent): void { +function onContextmenu(ev: PointerEvent): void { if (props.mock) { return; } @@ -621,10 +628,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus); } -function showRenoteMenu(): void { +async function showRenoteMenu() { if (props.mock) { return; } + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; function getUnrenote(): MenuItem { return { @@ -649,7 +658,6 @@ function showRenoteMenu(): void { }; if (isMyRenote) { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); os.popupMenu([ renoteDetailsMenu, getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 47bf365877..083e3e5da0 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> </div> - <MkNoteSub v-if="appearNote.replyId" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.replyId" :note="appearNote?.reply ?? null" :class="$style.replyTo"/> <div v-if="isRenote" :class="$style.renote"> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> @@ -143,8 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only :reactionEmojis="$appearNote.reactionEmojis" :myReaction="$appearNote.myReaction" :noteId="appearNote.id" - :maxNumber="16" - @mockUpdateMyReaction="emitUpdReaction" /> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ti ti-arrow-back-up"></i> @@ -233,7 +231,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, markRaw, onMounted, provide, ref, useTemplateRef } from 'vue'; +import { computed, inject, markRaw, provide, ref, useTemplateRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; @@ -358,7 +356,9 @@ const keymap = { if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, - 'o': () => galleryEl.value?.openGallery(), + 'o': () => { + galleryEl.value?.openGallery(); + }, 'v|enter': () => { if (appearNote.cw != null) { showContent.value = !showContent.value; @@ -448,8 +448,10 @@ if (appearNote.reactionAcceptance === 'likeOnly') { }); } -function renote() { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function renote() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton }); @@ -459,8 +461,10 @@ function renote() { subscribeManuallyToNoteCapture(); } -function reply(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function reply() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); os.post({ reply: appearNote, @@ -470,8 +474,10 @@ function reply(): void { }); } -function react(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function react() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -547,7 +553,7 @@ function toggleReact() { } } -function onContextmenu(ev: MouseEvent): void { +function onContextmenu(ev: PointerEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; @@ -569,9 +575,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus); } -function showRenoteMenu(): void { +async function showRenoteMenu() { if (!isMyRenote) return; - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + os.popupMenu([{ text: i18n.ts.unrenote, icon: 'ti ti-trash', diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue index 3f0a5a5247..371240ae4f 100644 --- a/packages/frontend/src/components/MkNoteDraftsDialog.vue +++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue @@ -118,7 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.draftActions" class="_buttons"> <template v-if="draft.scheduledAt != null && draft.isActuallyScheduled"> <MkButton - :class="$style.itemButton" small @click="cancelSchedule(draft)" > @@ -126,7 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> <!-- TODO <MkButton - :class="$style.itemButton" small @click="reSchedule(draft)" > @@ -136,7 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <MkButton v-else - :class="$style.itemButton" small @click="restoreDraft(draft)" > @@ -147,7 +144,6 @@ SPDX-License-Identifier: AGPL-3.0-only danger small :iconOnly="true" - :class="$style.itemButton" style="margin-left: auto;" @click="deleteDraft(draft)" > diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue index 7e900b28fa..e46456d614 100644 --- a/packages/frontend/src/components/MkNoteMediaGrid.vue +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -6,14 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <template v-for="file in note.files"> <div - v-if="((( - (prefer.s.nsfw === 'force' || file.isSensitive) && - prefer.s.nsfw !== 'ignore' - ) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) && - !showingFiles.has(file.id) - )" + v-if="isHiding(file)" :class="[$style.filePreview, { [$style.square]: square }]" - @click="showingFiles.add(file.id)" + @click="reveal(file)" > <MkDriveFileThumbnail :file="file" @@ -49,6 +44,7 @@ import * as Misskey from 'misskey-js'; import { notePage } from '@/filters/note.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; import bytes from '@/filters/bytes.js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; @@ -59,6 +55,24 @@ defineProps<{ }>(); const showingFiles = ref<Set<string>>(new Set()); + +function isHiding(file: Misskey.entities.DriveFile) { + if (shouldHideFileByDefault(file) && !showingFiles.value.has(file.id)) { + if (!file.isSensitive && !file.type.startsWith('image/')) { + return false; + } + return true; + } + return false; +} + +async function reveal(file: Misskey.entities.DriveFile) { + if (!(await canRevealFile(file))) { + return; + } + + showingFiles.value.add(file.id); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 45a74e3f02..6c70358c2c 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -121,7 +121,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ notification.invitation.room.name }} </div> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> - {{ i18n.ts._achievements._types['_' + notification.achievement].title }} + {{ i18n.ts._achievements._types[`_${notification.achievement}`].title }} </MkA> <MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> {{ i18n.ts.showFile }} @@ -136,15 +136,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</div> <div v-if="notification.message" :class="$style.text" style="opacity: 0.6; font-style: oblique;"> <i class="ti ti-quote" :class="$style.quote"></i> - <span>{{ notification.message }}</span> + <Mfm :text="notification.message" :author="notification.user" :plain="true" :nowrap="true"/> <i class="ti ti-quote" :class="$style.quote"></i> </div> </template> <template v-else-if="notification.type === 'receiveFollowRequest'"> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}</span> <div v-if="full && !followRequestDone" :class="$style.followRequestCommands"> - <MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> - <MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> + <MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"></i> {{ i18n.ts.accept }}</MkButton> + <MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"></i> {{ i18n.ts.reject }}</MkButton> </div> </template> <span v-else-if="notification.type === 'test'" :class="$style.text">{{ i18n.ts._notification.notificationWillBeDisplayedLikeThis }}</span> diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 7205e516d2..5300abd0cf 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -42,7 +42,7 @@ import { i18n } from '@/i18n.js'; type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>; const emit = defineEmits<{ - (ev: 'done', v: { excludeTypes: string[] }): void, + (ev: 'done', v: { excludeTypes: typeof notificationTypes[number][] }): void, (ev: 'closed'): void, }>(); diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index 7fa8c23c6c..abc4407d2a 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -42,7 +42,7 @@ const props = defineProps<{ value: unknown; }>(); -const collapsed = reactive({}); +const collapsed = reactive<Record<string, boolean>>({}); if (isObject(props.value)) { for (const key in props.value) { diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index a4c8ca0095..ad8fcf283c 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -88,7 +88,7 @@ const shouldEnableInfiniteScroll = computed(() => { return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll; }); -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; diff --git a/packages/frontend/src/components/MkPagingButtons.vue b/packages/frontend/src/components/MkPagingButtons.vue index fe59efd83a..10f432e50d 100644 --- a/packages/frontend/src/components/MkPagingButtons.vue +++ b/packages/frontend/src/components/MkPagingButtons.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.buttons"> <div v-if="prevDotVisible" :class="$style.headTailButtons"> <MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton> - <span class="ti ti-dots"/> + <span class="ti ti-dots"></span> </div> <MkButton @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> <div v-if="nextDotVisible" :class="$style.headTailButtons"> - <span class="ti ti-dots"/> + <span class="ti ti-dots"></span> <MkButton @click="onToTailButtonClicked">{{ max }}</MkButton> </div> </div> diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue index 4f1346b685..ca7bfd3a93 100644 --- a/packages/frontend/src/components/MkPolkadots.vue +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/> +<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"></div> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 305e9b5c4f..31567d2b84 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -90,7 +90,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const vote = async (id: number) => { if (props.readOnly || closed.value || isVoted.value) return; - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index b7c3d1f42d..bd36a0b97a 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -110,7 +110,7 @@ if (props.modelValue.expiresAt) { expiration.value = 'infinite'; } -function onInput(i, value) { +function onInput(i: number, value: string) { choices.value[i] = value; } @@ -122,7 +122,7 @@ function add() { // }); } -function remove(i) { +function remove(i: number) { choices.value = choices.value.filter((_, _i) => _i !== i); } diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index b3bcfcc137..d709286041 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <header :class="$style.header"> <div :class="$style.headerLeft"> <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> - <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu"> + <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" class="_button" @click="openAccountMenu"> <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/> </button> </div> @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.headerRightButtonText">{{ targetChannel.name }}</span> </button> </template> - <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified'" @click="toggleLocalOnly"> + <button v-if="visibility !== 'specified'" v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null" @click="toggleLocalOnly"> <span v-if="!localOnly"><i class="ti ti-rocket"></i></span> <span v-else><i class="ti ti-rocket-off"></i></span> </button> @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.visibleUsers"> <span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser"> <MkAcct :user="u"/> - <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button> + <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u.id)"><i class="ti ti-x"></i></button> </span> <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> </div> @@ -77,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div v-if="targetChannel" :class="$style.colorBar" :style="{ background: targetChannel.color }"></div> - <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"></textarea> <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> @@ -108,13 +108,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </footer> <datalist id="hashtags"> - <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/> + <option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"></option> </datalist> </div> </template> <script lang="ts" setup> -import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue'; +import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted, onBeforeUnmount } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -227,6 +227,10 @@ const targetChannel = shallowRef(props.channel); const serverDraftId = ref<string | null>(null); const postFormActions = getPluginHandlers('post_form_action'); +let textAutocomplete: Autocomplete | null = null; +let cwAutocomplete: Autocomplete | null = null; +let hashtagAutocomplete: Autocomplete | null = null; + const uploader = useUploader({ multiple: true, }); @@ -329,8 +333,8 @@ const canSaveAsServerDraft = computed((): boolean => { return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null); }); -const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); -const hashtags = computed(store.makeGetterSetter('postFormHashtags')); +const withHashtags = store.model('postFormWithHashtags'); +const hashtags = store.model('postFormHashtags'); watch(text, () => { checkMissingMention(); @@ -476,6 +480,7 @@ function togglePoll() { } function addTag(tag: string) { + if (textareaEl.value == null) return; insertTextAtCursor(textareaEl.value, ` #${tag} `); } @@ -486,7 +491,7 @@ function focus() { } } -function chooseFileFromPc(ev: MouseEvent) { +function chooseFileFromPc(ev: PointerEvent) { if (props.mock) return; os.chooseFileFromPc({ multiple: true }).then(files => { @@ -495,7 +500,7 @@ function chooseFileFromPc(ev: MouseEvent) { }); } -function chooseFileFromDrive(ev: MouseEvent) { +function chooseFileFromDrive(ev: PointerEvent) { if (props.mock) return; chooseDriveFile({ multiple: true }).then(driveFiles => { @@ -503,18 +508,18 @@ function chooseFileFromDrive(ev: MouseEvent) { }); } -function detachFile(id) { +function detachFile(id: Misskey.entities.DriveFile['id']) { files.value = files.value.filter(x => x.id !== id); } -function updateFileSensitive(file, sensitive) { +function updateFileSensitive(file: Misskey.entities.DriveFile, isSensitive: boolean) { if (props.mock) { - emit('fileChangeSensitive', file.id, sensitive); + emit('fileChangeSensitive', file.id, isSensitive); } - files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = sensitive; + files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = isSensitive; } -function updateFileName(file, name) { +function updateFileName(file: Misskey.entities.DriveFile, name: Misskey.entities.DriveFile['name']) { files.value[files.value.findIndex(x => x.id === file.id)].name = name; } @@ -528,7 +533,6 @@ function setVisibility() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility.value, isSilenced: $i.isSilenced, - localOnly: localOnly.value, anchorElement: visibilityButton.value, ...(replyTargetNote.value ? { isReplyVisibilitySpecified: replyTargetNote.value.visibility === 'specified' } : {}), }, { @@ -704,8 +708,8 @@ function addVisibleUser() { }); } -function removeVisibleUser(user) { - visibleUsers.value = erase(user, visibleUsers.value); +function removeVisibleUser(id: string) { + visibleUsers.value = visibleUsers.value.filter(u => u.id !== id); } function clear() { @@ -742,7 +746,8 @@ const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; async function onPaste(ev: ClipboardEvent) { if (props.mock) return; - if (!ev.clipboardData) return; + if (ev.clipboardData == null) return; + if (textareaEl.value == null) return; let pastedFiles: File[] = []; for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { @@ -767,39 +772,42 @@ async function onPaste(ev: ClipboardEvent) { if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) { ev.preventDefault(); - os.confirm({ + const { canceled } = await os.confirm({ type: 'info', text: i18n.ts.quoteQuestion, - }).then(({ canceled }) => { - if (canceled) { - insertTextAtCursor(textareaEl.value, paste); - return; - } - - quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; }); + + if (canceled) { + insertTextAtCursor(textareaEl.value, paste); + return; + } + + quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; } if (paste.length > 1000) { ev.preventDefault(); - os.confirm({ + + const { canceled } = await os.confirm({ type: 'info', text: i18n.ts.attachAsFileQuestion, - }).then(({ canceled }) => { - if (canceled) { - insertTextAtCursor(textareaEl.value, paste); - return; - } - - const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); - const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); - uploader.addFiles([file]); }); + + if (canceled) { + insertTextAtCursor(textareaEl.value, paste); + return; + } + + const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); + const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); + uploader.addFiles([file]); } } -function onDragover(ev) { - if (!ev.dataTransfer.items[0]) return; +function onDragover(ev: DragEvent) { + if (ev.dataTransfer == null) return; + if (ev.dataTransfer.items[0] == null) return; + const isFile = ev.dataTransfer.items[0].kind === 'file'; if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); @@ -852,13 +860,32 @@ function onDrop(ev: DragEvent): void { //#endregion } +type StoredDrafts = { + [key: string]: { + updatedAt: string; + data: { + text: string; + useCw: boolean; + cw: string | null; + visibility: 'public' | 'home' | 'followers' | 'specified'; + localOnly: boolean; + files: Misskey.entities.DriveFile[]; + poll: PollEditorModelValue | null; + visibleUserIds?: string[]; + quoteId: string | null; + reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; + scheduledAt: number | null; + }; + }; +}; + function saveDraft() { if (props.instant || props.mock) return; - const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); + const draftsData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as StoredDrafts; - draftData[draftKey.value] = { - updatedAt: new Date(), + draftsData[draftKey.value] = { + updatedAt: new Date().toISOString(), data: { text: text.value, useCw: useCw.value, @@ -874,15 +901,15 @@ function saveDraft() { }, }; - miLocalStorage.setItem('drafts', JSON.stringify(draftData)); + miLocalStorage.setItem('drafts', JSON.stringify(draftsData)); } function deleteDraft() { - const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); + const draftsData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as StoredDrafts; - delete draftData[draftKey.value]; + delete draftsData[draftKey.value]; - miLocalStorage.setItem('drafts', JSON.stringify(draftData)); + miLocalStorage.setItem('drafts', JSON.stringify(draftsData)); } async function saveServerDraft(options: { @@ -924,8 +951,8 @@ async function uploadFiles() { } } -async function post(ev?: MouseEvent) { - if (ev) { +async function post(ev?: PointerEvent) { + if (ev != null) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; if (el && prefer.s.animation) { @@ -999,7 +1026,7 @@ async function post(ev?: MouseEvent) { channelId: targetChannel.value ? targetChannel.value.id : undefined, poll: poll.value, cw: useCw.value ? cw.value ?? '' : null, - localOnly: localOnly.value, + localOnly: visibility.value === 'specified' ? false : localOnly.value, visibility: visibility.value, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, reactionAcceptance: reactionAcceptance.value, @@ -1138,11 +1165,12 @@ function cancel() { function insertMention() { os.selectUser({ localOnly: localOnly.value, includeSelf: true }).then(user => { + if (textareaEl.value == null) return; insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' '); }); } -async function insertEmoji(ev: MouseEvent) { +async function insertEmoji(ev: PointerEvent) { textAreaReadOnly.value = true; const target = ev.currentTarget ?? ev.target; if (target == null) return; @@ -1166,21 +1194,45 @@ async function insertEmoji(ev: MouseEvent) { }, () => { textAreaReadOnly.value = false; - nextTick(() => focus()); + nextTick(() => { + if (textareaEl.value) { + textareaEl.value.focus(); + textareaEl.value.setSelectionRange(pos, posEnd); + } + }); }, ); } -async function insertMfmFunction(ev: MouseEvent) { +async function insertMfmFunction(ev: PointerEvent) { if (textareaEl.value == null) return; + let pos = textareaEl.value.selectionStart ?? 0; + let posEnd = textareaEl.value.selectionEnd ?? text.value.length; mfmFunctionPicker( ev.currentTarget ?? ev.target, - textareaEl.value, - text, + (tag) => { + if (pos === posEnd) { + text.value = `${text.value.substring(0, pos)}$[${tag} ]${text.value.substring(pos)}`; + pos += tag.length + 3; + posEnd = pos; + } else { + text.value = `${text.value.substring(0, pos)}$[${tag} ${text.value.substring(pos, posEnd)}]${text.value.substring(posEnd)}`; + pos += tag.length + 3; + posEnd = pos; + } + }, + () => { + nextTick(() => { + if (textareaEl.value) { + textareaEl.value.focus(); + textareaEl.value.setSelectionRange(pos, posEnd); + } + }); + }, ); } -function showActions(ev: MouseEvent) { +function showActions(ev: PointerEvent) { os.popupMenu(postFormActions.map(action => ({ text: action.title, action: () => { @@ -1198,7 +1250,7 @@ function showActions(ev: MouseEvent) { const postAccount = ref<Misskey.entities.UserDetailed | null>(null); -async function openAccountMenu(ev: MouseEvent) { +async function openAccountMenu(ev: PointerEvent) { if (props.mock) return; function showDraftsDialog(scheduled: boolean) { @@ -1288,12 +1340,12 @@ async function openAccountMenu(ev: MouseEvent) { }, { type: 'divider' }, ...items], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } -function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) { +function showPerUploadItemMenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { +function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.contextMenu(menu, ev); } @@ -1360,16 +1412,15 @@ onMounted(() => { }); } - // TODO: detach when unmount - if (textareaEl.value) new Autocomplete(textareaEl.value, text); - if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw); - if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags); + if (textareaEl.value) textAutocomplete = new Autocomplete(textareaEl.value, text); + if (cwInputEl.value) cwAutocomplete = new Autocomplete(cwInputEl.value, cw); + if (hashtagsInputEl.value) hashtagAutocomplete = new Autocomplete(hashtagsInputEl.value, hashtags); nextTick(() => { // 書きかけの投稿を復元 if (!props.instant && !props.mention && !props.specified && !props.mock) { - const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value]; - if (draft) { + const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value] as StoredDrafts[string] | undefined; + if (draft != null) { text.value = draft.data.text; useCw.value = draft.data.useCw; cw.value = draft.data.cw; @@ -1420,6 +1471,19 @@ onMounted(() => { }); }); +onBeforeUnmount(() => { + uploader.abortAll(); + if (textAutocomplete) { + textAutocomplete.detach(); + } + if (cwAutocomplete) { + cwAutocomplete.detach(); + } + if (hashtagAutocomplete) { + hashtagAutocomplete.detach(); + } +}); + async function canClose() { if (!uploader.allItemsUploaded.value) { const { canceled } = await os.confirm({ @@ -1469,9 +1533,6 @@ defineExpose({ padding: 8px; } -.account { -} - .avatar { display: block; width: 28px; diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index f429db94df..58adb16954 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-show="props.modelValue.length != 0" :class="$style.root"> - <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{ element }"> + <MkDraggable + :modelValue="props.modelValue" + :class="$style.files" + direction="horizontal" + withGaps + @update:modelValue="v => emit('update:modelValue', v)" + > + <template #default="{ item }"> <div :class="$style.file" role="button" tabindex="0" - @click="showFileMenu(element, $event)" - @keydown.space.enter="showFileMenu(element, $event)" - @contextmenu.prevent="showFileMenu(element, $event)" + @click="showFileMenu(item, $event)" + @keydown.space.enter="showFileMenu(item, $event)" + @contextmenu.prevent.stop="showFileMenu(item, $event)" > - <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> - <div v-if="element.isSensitive" :class="$style.sensitive"> + <!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる --> + <MkDriveFileThumbnail style="pointer-events: none;" :data-id="item.id" :class="$style.thumbnail" :file="item" fit="cover"/> + <div v-if="item.isSensitive" :class="$style.sensitive" style="pointer-events: none;"> <i class="ti ti-eye-exclamation" style="margin: auto;"></i> </div> </div> </template> - </Sortable> + </MkDraggable> <p :class="[$style.remain, { [$style.exceeded]: props.modelValue.length > 16, @@ -33,11 +40,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, inject } from 'vue'; +import { inject } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -45,8 +53,6 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ modelValue: Misskey.entities.DriveFile[]; detachMediaFn?: (id: string) => void; @@ -91,7 +97,7 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { globalEvents.emit('driveFilesDeleted', [file]); } -function toggleSensitive(file) { +function toggleSensitive(file: Misskey.entities.DriveFile) { if (mock) { emit('changeSensitive', file, !file.isSensitive); return; @@ -105,7 +111,7 @@ function toggleSensitive(file) { }); } -async function rename(file) { +async function rename(file: Misskey.entities.DriveFile) { if (mock) return; const { canceled, result } = await os.inputText({ @@ -143,7 +149,7 @@ async function describe(file: Misskey.entities.DriveFile) { }); } -function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { +function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | KeyboardEvent): void { if (menuShowing) return; const isImage = file.type.startsWith('image/'); @@ -221,7 +227,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar position: relative; width: 64px; height: 64px; - margin-right: 4px; border-radius: 4px; overflow: hidden; cursor: move; diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue index 70b111513c..1ce608dda9 100644 --- a/packages/frontend/src/components/MkPreferenceContainer.vue +++ b/packages/frontend/src/components/MkPreferenceContainer.vue @@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{ const isAccountOverrided = ref(prefer.isAccountOverrided(props.k)); const isSyncEnabled = ref(prefer.isSyncEnabled(props.k)); -function showMenu(ev: MouseEvent, contextmenu?: boolean) { +function showMenu(ev: PointerEvent, contextmenu?: boolean) { const i = window.setInterval(() => { isAccountOverrided.value = prefer.isAccountOverrided(props.k); isSyncEnabled.value = prefer.isSyncEnabled(props.k); diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index 6c7bf6be6b..c589cd9685 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -12,11 +12,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="flag" :class="$style.preview__content1__switch_button"> <span>Switch is now {{ flag ? 'on' : 'off' }}</span> </MkSwitch> - <div :class="$style.preview__content1__input"> - <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> - <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> - <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> - </div> <div :class="$style.preview__content1__button"> <MkButton inline>This is</MkButton> <MkButton inline primary>the button</MkButton> @@ -40,15 +35,12 @@ import * as config from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; -import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; import { $i } from '@/i.js'; import { chooseDriveFile } from '@/utility/drive.js'; const text = ref(''); const flag = ref(true); -const radio = ref('misskey'); const mfm = ref(`Hello world! This is an @example mention. BTW you are @${$i ? $i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`); const openDialog = async () => { @@ -89,7 +81,7 @@ const selectUser = async () => { await os.selectUser(); }; -const openMenu = async (ev: Event) => { +const openMenu = async (ev: PointerEvent) => { os.popupMenu([{ type: 'label', text: 'Fruits', diff --git a/packages/frontend/src/components/MkPreviewWithControls.vue b/packages/frontend/src/components/MkPreviewWithControls.vue new file mode 100644 index 0000000000..ad5fd2a01d --- /dev/null +++ b/packages/frontend/src/components/MkPreviewWithControls.vue @@ -0,0 +1,93 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <div :class="$style.container"> + <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> + <div :class="$style.previewContent"> + <slot name="preview"></slot> + </div> + <div v-if="previewLoading" :class="$style.previewLoading"> + <MkLoading/> + </div> + </div> + <div :class="$style.controls"> + <slot name="controls"></slot> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + previewLoading?: boolean; +}>(), { + previewLoading: false, +}); + +defineSlots<{ + preview: () => any; + controls: () => any; +}>(); +</script> + +<style lang="scss" module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.previewContent { + position: relative; + width: 100%; + height: 100%; + overflow: clip; +} + +.previewLoading { + position: absolute; + inset: 0; + background-color: color(from var(--MI_THEME-panel) srgb r g b / 0.7); + display: flex; + justify-content: center; + align-items: center; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 89aca5d29b..4d936758c5 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <slot/> + <slot></slot> </div> </template> diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 38441b0ea6..cba9b47c56 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -144,7 +144,7 @@ async function unsubscribe() { } function encode(buffer: ArrayBuffer | null) { - return btoa(String.fromCharCode.apply(null, buffer ? new Uint8Array(buffer) as any : [])); + return btoa(String.fromCharCode(...(buffer != null ? new Uint8Array(buffer) : []))); } /** diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue deleted file mode 100644 index a7d77dd118..0000000000 --- a/packages/frontend/src/components/MkRadio.vue +++ /dev/null @@ -1,136 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div - v-adaptive-border - :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]" - :aria-checked="checked" - :aria-disabled="disabled" - role="checkbox" - @click="toggle" -> - <input - type="radio" - :disabled="disabled" - :class="$style.input" - > - <span :class="$style.button"> - <span></span> - </span> - <span :class="$style.label"><slot></slot></span> -</div> -</template> - -<script lang="ts" setup generic="T extends unknown"> -import { computed } from 'vue'; - -const props = defineProps<{ - modelValue: T; - value: T; - disabled?: boolean; -}>(); - -const emit = defineEmits<{ - (ev: 'update:modelValue', value: T): void; -}>(); - -const checked = computed(() => props.modelValue === props.value); - -function toggle(): void { - if (props.disabled) return; - emit('update:modelValue', props.value); -} -</script> - -<style lang="scss" module> -.root { - position: relative; - display: inline-flex; - align-items: center; - text-align: left; - cursor: pointer; - padding: 7px 10px; - min-width: 60px; - background-color: var(--MI_THEME-panel); - background-clip: padding-box !important; - border: solid 1px var(--MI_THEME-panel); - border-radius: 6px; - font-size: 90%; - transition: all 0.2s; - user-select: none; - - &.disabled { - opacity: 0.6; - cursor: not-allowed !important; - } - - &:hover { - border-color: var(--MI_THEME-inputBorderHover) !important; - } - - &:focus-within { - outline: none; - box-shadow: 0 0 0 2px var(--MI_THEME-focus); - } - - &.checked { - background-color: var(--MI_THEME-accentedBg) !important; - border-color: var(--MI_THEME-accentedBg) !important; - color: var(--MI_THEME-accent); - cursor: default !important; - - > .button { - border-color: var(--MI_THEME-accent); - - &::after { - background-color: var(--MI_THEME-accent); - transform: scale(1); - opacity: 1; - } - } - } -} - -.input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; -} - -.button { - position: relative; - display: inline-block; - width: 14px; - height: 14px; - background: none; - border: solid 2px var(--MI_THEME-inputBorder); - border-radius: 100%; - transition: inherit; - - &::after { - content: ''; - display: block; - position: absolute; - top: 3px; - right: 3px; - bottom: 3px; - left: 3px; - border-radius: 100%; - opacity: 0; - transform: scale(0); - transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); - } -} - -.label { - margin-left: 8px; - display: block; - line-height: 20px; - cursor: pointer; -} -</style> diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 426a1d2c2b..e2210e858e 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -3,99 +3,225 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> +<template> +<div :class="{ [$style.vertical]: vertical }"> + <div :class="$style.label"> + <slot name="label"></slot> + </div> + + <div :class="$style.body"> + <div + v-for="option in options" + :key="getKey(option.value)" + v-adaptive-border + :class="[$style.optionRoot, { [$style.disabled]: option.disabled, [$style.checked]: model === option.value }]" + :aria-checked="model === option.value" + :aria-disabled="option.disabled" + role="checkbox" + @click="toggle(option)" + > + <input + type="radio" + :disabled="option.disabled" + :class="$style.optionInput" + > + <span :class="$style.optionButton"> + <span></span> + </span> + <div :class="$style.optionContent"> + <i v-if="option.icon" :class="[$style.optionIcon, option.icon]" :style="option.iconStyle"></i> + <div> + <slot v-if="option.slotId != null" :name="`option-${option.slotId as SlotNames}`"></slot> + <template v-else> + <div :style="option.labelStyle">{{ option.label ?? option.value }}</div> + <div v-if="option.caption" :class="$style.optionCaption">{{ option.caption }}</div> + </template> + </div> + </div> + </div> + </div> + + <div :class="$style.caption"> + <slot name="caption"></slot> + </div> +</div> +</template> + <script lang="ts"> -import { Comment, defineComponent, h, ref, watch } from 'vue'; -import MkRadio from './MkRadio.vue'; -import type { VNode } from 'vue'; +import type { StyleValue } from 'vue'; +import type { OptionValue } from '@/types/option-value.js'; + +export type MkRadiosOption<T = OptionValue, S = string> = { + value: T; + slotId?: S; + label?: string; + labelStyle?: StyleValue; + icon?: string; + iconStyle?: StyleValue; + caption?: string; + disabled?: boolean; +}; +</script> -export default defineComponent({ - props: { - modelValue: { - required: false, - }, - vertical: { - type: Boolean, - default: false, - }, - }, - setup(props, context) { - const value = ref(props.modelValue); - watch(value, () => { - context.emit('update:modelValue', value.value); - }); - watch(() => props.modelValue, v => { - value.value = v; - }); - if (!context.slots.default) return null; - let options = context.slots.default(); - const label = context.slots.label && context.slots.label(); - const caption = context.slots.caption && context.slots.caption(); +<script setup lang="ts" generic="const T extends MkRadiosOption"> +defineProps<{ + options: T[]; + vertical?: boolean; +}>(); - // なぜかFragmentになることがあるため - if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[]; +type SlotNames = NonNullable<T extends MkRadiosOption<any, infer U> ? U : never>; - // vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる) - options = options.filter(vnode => vnode.type !== Comment); +defineSlots<{ + label?: () => void; + caption?: () => void; +} & { + [K in `option-${SlotNames}`]: () => void; +}>(); - return () => h('div', { - class: [ - 'novjtcto', - ...(props.vertical ? ['vertical'] : []), - ], - }, [ - ...(label ? [h('div', { - class: 'label', - }, label)] : []), - h('div', { - class: 'body', - }, options.map(option => h(MkRadio, { - key: option.key as string, - value: option.props?.value, - disabled: option.props?.disabled, - modelValue: value.value, - 'onUpdate:modelValue': _v => value.value = _v, - }, () => option.children)), - ), - ...(caption ? [h('div', { - class: 'caption', - }, caption)] : []), - ]); - }, -}); +const model = defineModel<T['value']>({ required: true }); + +function getKey(value: OptionValue): PropertyKey { + if (value === null) return '___null___'; + return value; +} + +function toggle(o: MkRadiosOption): void { + if (o.disabled) return; + model.value = o.value; +} </script> -<style lang="scss"> -.novjtcto { - > .label { - font-size: 0.85em; - padding: 0 0 8px 0; - user-select: none; +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - &:empty { - display: none; - } + &:empty { + display: none; } +} + +.body { + display: flex; + gap: 10px; + flex-wrap: wrap; +} - > .body { - display: flex; - gap: 10px; - flex-wrap: wrap; +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); + + &:empty { + display: none; } +} - > .caption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); +.vertical > .body { + flex-direction: column; +} - &:empty { - display: none; - } +.optionRoot { + position: relative; + display: inline-flex; + align-items: center; + text-align: left; + cursor: pointer; + padding: 8px 10px; + min-width: 60px; + background-color: var(--MI_THEME-panel); + background-clip: padding-box !important; + border: solid 1px var(--MI_THEME-panel); + border-radius: 6px; + font-size: 90%; + transition: all 0.2s; + user-select: none; + + &.disabled { + opacity: 0.6; + cursor: not-allowed !important; + } + + &:hover { + border-color: var(--MI_THEME-inputBorderHover) !important; + } + + &:focus-within { + outline: none; + box-shadow: 0 0 0 2px var(--MI_THEME-focus); } - &.vertical { - > .body { - flex-direction: column; + &.checked { + background-color: var(--MI_THEME-accentedBg) !important; + border-color: var(--MI_THEME-accentedBg) !important; + color: var(--MI_THEME-accent); + cursor: default !important; + + .optionButton { + border-color: var(--MI_THEME-accent); + + &::after { + background-color: var(--MI_THEME-accent); + transform: scale(1); + opacity: 1; + } + } + + .optionCaption { + color: color(from var(--MI_THEME-accent) srgb r g b / 0.75); } } } + +.optionInput { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; +} + +.optionButton { + position: relative; + display: inline-block; + width: 14px; + height: 14px; + background: none; + border: solid 2px var(--MI_THEME-inputBorder); + border-radius: 100%; + transition: inherit; + + &::after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } +} + +.optionContent { + display: flex; + align-items: center; + gap: 6px; + margin-left: 8px; +} + +.optionCaption { + font-size: 0.85em; + padding: 2px 0 0 0; + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); + transition: all 0.2s; +} + +.optionIcon { + flex-shrink: 0; +} </style> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 7c60288883..a89f947fa7 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -153,7 +153,7 @@ async function toggleReaction() { } } -async function menu(ev) { +async function menu(ev: PointerEvent) { let menuItems: MenuItem[] = []; if (canGetInfo.value) { diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index bd9ef50157..67fd570b41 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :myReaction="props.myReaction" @reactionToggled="onMockToggleReaction" /> - <slot v-if="hasMoreReactions" name="more"/> + <slot v-if="hasMoreReactions" name="more"></slot> </component> </template> @@ -32,11 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { inject, watch, ref } from 'vue'; import { TransitionGroup } from 'vue'; +import { isSupportedEmoji } from '@@/js/emojilist.js'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; import { customEmojisMap } from '@/custom-emojis.js'; -import { isSupportedEmoji } from '@@/js/emojilist.js'; import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ @@ -60,8 +60,8 @@ const initialReactions = new Set(Object.keys(props.reactions)); const _reactions = ref<[string, number][]>([]); const hasMoreReactions = ref(false); -if (props.myReaction && !Object.keys(_reactions.value).includes(props.myReaction)) { - _reactions.value[props.myReaction] = props.reactions[props.myReaction]; +if (props.myReaction != null && !(props.myReaction in props.reactions)) { + _reactions.value.push([props.myReaction, props.reactions[props.myReaction]]); } function onMockToggleReaction(emoji: string, count: number) { diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index a204bc3bf1..6ab7a01ce7 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -98,7 +98,7 @@ async function renderChart() { data: data as any, borderWidth: 0, borderRadius: 3, - backgroundColor(c) { + backgroundColor(c: any) { const v = c.dataset.data[c.dataIndex] as unknown as typeof data[0]; const value = v.v; const m = max(v.y); @@ -179,7 +179,7 @@ async function renderChart() { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; + const v = context[0].dataset.data[context[0].dataIndex] as unknown as typeof data[0]; return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000))); }, label(context) { diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index 21c20f944b..5b18bab8c9 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; +import type { ScatterDataPoint } from 'chart.js'; import tinycolor from 'tinycolor2'; import { store } from '@/store.js'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; @@ -18,6 +19,12 @@ import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; import { misskeyApi } from '@/utility/misskey-api.js'; +interface RetentionPoint extends ScatterDataPoint { + x: number; + y: number; + d: string; +} + initChart(); const chartEl = useTemplateRef('chartEl'); @@ -62,14 +69,14 @@ onMounted(async () => { fill: false, tension: 0.4, data: [{ - x: '0', + x: 0, y: 100, d: getYYYYMMDD(new Date(record.createdAt)), }, ...Object.entries(record.data).sort((a, b) => getDate(a[0]) > getDate(b[0]) ? 1 : -1).map(([k, v], i) => ({ - x: (i + 1).toString(), + x: i + 1, y: (v / record.users) * 100, d: getYYYYMMDD(new Date(record.createdAt)), - }))] as any, + }))], })), }, options: { @@ -111,11 +118,11 @@ onMounted(async () => { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex] as unknown as { x: string, y: number, d: string }; + const v = context[0].dataset.data[context[0].dataIndex] as RetentionPoint; return `${v.x} days later`; }, label(context) { - const v = context.dataset.data[context.dataIndex] as unknown as { x: string, y: number, d: string }; + const v = context.dataset.data[context.dataIndex] as RetentionPoint; const p = Math.round(v.y) + '%'; return `${v.d} ${p}`; }, diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 937804703d..651165136a 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -55,9 +55,9 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkLoading from '@/components/global/MkLoading.vue'; const emit = defineEmits<{ - (ev: 'done', value: Misskey.entities.Role[]), - (ev: 'close'), - (ev: 'closed'), + (ev: 'done', value: Misskey.entities.Role[]): void; + (ev: 'close'): void; + (ev: 'closed'): void; }>(); const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index f130145e36..6f6957d504 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -export type OptionValue = string | number | null; +import type { OptionValue } from '@/types/option-value.js'; export type ItemOption<T extends OptionValue = OptionValue> = { type?: 'option'; diff --git a/packages/frontend/src/components/MkServerSetupWizard.vue b/packages/frontend/src/components/MkServerSetupWizard.vue index 8e3b41e754..462ded6de3 100644 --- a/packages/frontend/src/components/MkServerSetupWizard.vue +++ b/packages/frontend/src/components/MkServerSetupWizard.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root" class="_gaps_m"> +<div class="_gaps_m"> <MkInput v-model="q_name" data-cy-server-name> <template #label>{{ i18n.ts.instanceName }}</template> </MkInput> @@ -14,19 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-settings-question"></i></template> <div class="_gaps_s"> - <MkRadios v-model="q_use" :vertical="true"> - <option value="single"> - <div><i class="ti ti-user"></i> <b>{{ i18n.ts._serverSetupWizard._use.single }}</b></div> - <div>{{ i18n.ts._serverSetupWizard._use.single_description }}</div> - </option> - <option value="group"> - <div><i class="ti ti-lock"></i> <b>{{ i18n.ts._serverSetupWizard._use.group }}</b></div> - <div>{{ i18n.ts._serverSetupWizard._use.group_description }}</div> - </option> - <option value="open"> - <div><i class="ti ti-world"></i> <b>{{ i18n.ts._serverSetupWizard._use.open }}</b></div> - <div>{{ i18n.ts._serverSetupWizard._use.open_description }}</div> - </option> + <MkRadios + v-model="q_use" + :options="[ + { value: 'single', label: i18n.ts._serverSetupWizard._use.single, icon: 'ti ti-user', caption: i18n.ts._serverSetupWizard._use.single_description }, + { value: 'group', label: i18n.ts._serverSetupWizard._use.group, icon: 'ti ti-lock', caption: i18n.ts._serverSetupWizard._use.group_description }, + { value: 'open', label: i18n.ts._serverSetupWizard._use.open, icon: 'ti ti-world', caption: i18n.ts._serverSetupWizard._use.open_description }, + ]" + vertical + > </MkRadios> <MkInfo v-if="q_use === 'single'">{{ i18n.ts._serverSetupWizard._use.single_youCanCreateMultipleAccounts }}</MkInfo> @@ -40,10 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-users"></i></template> <div class="_gaps_s"> - <MkRadios v-model="q_scale" :vertical="true"> - <option value="small"><i class="ti ti-user"></i> {{ i18n.ts._serverSetupWizard._scale.small }}</option> - <option value="medium"><i class="ti ti-users"></i> {{ i18n.ts._serverSetupWizard._scale.medium }}</option> - <option value="large"><i class="ti ti-users-group"></i> {{ i18n.ts._serverSetupWizard._scale.large }}</option> + <MkRadios + v-model="q_scale" + :options="[ + { value: 'small', label: i18n.ts._serverSetupWizard._scale.small, icon: 'ti ti-user' }, + { value: 'medium', label: i18n.ts._serverSetupWizard._scale.medium, icon: 'ti ti-users' }, + { value: 'large', label: i18n.ts._serverSetupWizard._scale.large, icon: 'ti ti-users-group' }, + ]" + vertical + > </MkRadios> <MkInfo v-if="q_scale === 'large'"><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.largeScaleServerAdvice }}</MkInfo> @@ -57,9 +58,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}<br><MkLink target="_blank" url="https://wikipedia.org/wiki/Fediverse">{{ i18n.ts.learnMore }}</MkLink></div> - <MkRadios v-model="q_federation" :vertical="true"> - <option value="yes">{{ i18n.ts.yes }}</option> - <option value="no">{{ i18n.ts.no }}</option> + <MkRadios + v-model="q_federation" + :options="[ + { value: 'yes', label: i18n.ts.yes }, + { value: 'no', label: i18n.ts.no }, + ]" + vertical + > </MkRadios> <MkInfo v-if="q_federation === 'yes'">{{ i18n.ts._serverSetupWizard.youCanConfigureMoreFederationSettingsLater }}</MkInfo> @@ -212,9 +218,9 @@ const props = withDefaults(defineProps<{ }); const q_name = ref(''); -const q_use = ref('single'); -const q_scale = ref('small'); -const q_federation = ref('yes'); +const q_use = ref<'single' | 'group' | 'open'>('single'); +const q_scale = ref<'small' | 'medium' | 'large'>('small'); +const q_federation = ref<'yes' | 'no'>('no'); const q_remoteContentsCleaning = ref(true); const q_adminName = ref(''); const q_adminEmail = ref(''); @@ -239,7 +245,7 @@ const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => { enableReactionsBuffering, clientOptions: { entrancePageStyle: q_use.value === 'open' ? 'classic' : 'simple', - } as any, + }, }; }); @@ -370,8 +376,3 @@ function applySettings() { }); } </script> - -<style lang="scss" module> -.root { -} -</style> diff --git a/packages/frontend/src/components/MkServerSetupWizardDialog.vue b/packages/frontend/src/components/MkServerSetupWizardDialog.vue index ea2c5dd47f..1d03438f83 100644 --- a/packages/frontend/src/components/MkServerSetupWizardDialog.vue +++ b/packages/frontend/src/components/MkServerSetupWizardDialog.vue @@ -33,7 +33,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkServerSetupWizard from '@/components/MkServerSetupWizard.vue'; const emit = defineEmits<{ - (ev: 'closed'), + (ev: 'closed'): void; }>(); const windowEl = useTemplateRef('windowEl'); diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue index 4c73eab3f5..89ec6373cf 100644 --- a/packages/frontend/src/components/MkSignin.input.vue +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -78,7 +78,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'usernameSubmitted', v: string): void; - (ev: 'passkeyClick', v: MouseEvent): void; + (ev: 'passkeyClick', v: PointerEvent): void; }>(); const host = toUnicode(configHost); diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue index 27ffc724ae..3ac809cdbf 100644 --- a/packages/frontend/src/components/MkSortOrderEditor.vue +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts" generic="T extends string"> import { toRefs } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; import MkTagItem from '@/components/MkTagItem.vue'; import MkButton from '@/components/MkButton.vue'; -import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; const emit = defineEmits<{ (ev: 'update', sortOrders: SortOrder<T>[]): void; @@ -55,7 +55,7 @@ function onToggleSortOrderButtonClicked(order: SortOrder<T>) { emitOrder(currentOrders.value); } -function onAddSortOrderButtonClicked(ev: MouseEvent) { +function onAddSortOrderButtonClicked(ev: PointerEvent) { const menuItems: MenuItem[] = props.baseOrderKeyNames .filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey)) .map(it => { diff --git a/packages/frontend/src/components/MkSpot.vue b/packages/frontend/src/components/MkSpot.vue index 4a8ebb5f94..4bd11fe938 100644 --- a/packages/frontend/src/components/MkSpot.vue +++ b/packages/frontend/src/components/MkSpot.vue @@ -88,7 +88,7 @@ function setPosition() { bodyEl.value.style.top = data.top + 'px'; } -let loopHandler; +let loopHandler: number | null = null; onMounted(() => { nextTick(() => { @@ -104,7 +104,7 @@ onMounted(() => { }); onUnmounted(() => { - window.cancelAnimationFrame(loopHandler); + if (loopHandler != null) window.cancelAnimationFrame(loopHandler); }); </script> diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index bc6ebf0918..9784d8e017 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -350,13 +350,12 @@ function connectChannel() { connections.main = stream.useChannel('main'); connections.main.on('mention', prepend); } else if (props.src === 'directs') { - const onNote = note => { + connections.main = stream.useChannel('main'); + connections.main.on('mention', note => { if (note.visibility === 'specified') { prepend(note); } - }; - connections.main = stream.useChannel('main'); - connections.main.on('mention', onNote); + }); } else if (props.src === 'list') { if (props.list == null) return; connections.userList = stream.useChannel('userList', { diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue index 6ee2e347a5..91f071fe63 100644 --- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -137,8 +137,8 @@ watch(visibility, () => { } }); -function onNotification(notification) { - const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; +function onNotification(notification: Misskey.entities.Notification) { + const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type as typeof notificationTypes[number]) : false; if (isMuted || window.document.visibilityState === 'visible') { if (store.s.realtimeMode) { useStream().send('readNotification'); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 236afa127c..585a628a96 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -82,7 +82,7 @@ export type SuperMenuDef = { text: string; danger?: boolean; active?: boolean; - action: (ev: MouseEvent) => Awaitable<void>; + action: (ev: PointerEvent) => Awaitable<void>; } | { type?: 'link'; to: string; diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts index 19e4eea733..f2ce55acc4 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts +++ b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts @@ -25,7 +25,7 @@ export type MkSystemWebhookResult = { }; export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> { - const { result } = await new Promise<{ result: MkSystemWebhookResult | null }>(async resolve => { + const { result } = await new Promise<{ result: MkSystemWebhookResult | null }>(resolve => { const { dispose } = os.popup( defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')), props, diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue index cd72204fce..1536b14455 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.vue +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -245,7 +245,7 @@ onMounted(async () => { secret.value = res.secret; isActive.value = res.isActive; for (const ev of Object.keys(events.value)) { - events.value[ev] = res.on.includes(ev as SystemWebhookEventType); + events.value[ev as SystemWebhookEventType] = res.on.includes(ev as SystemWebhookEventType); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (ex: any) { diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue index 9798e2c3b3..a6342ec2e1 100644 --- a/packages/frontend/src/components/MkTabs.vue +++ b/packages/frontend/src/components/MkTabs.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab<K = string> = { key: K; - onClick?: (ev: MouseEvent) => void; + onClick?: (ev: PointerEvent) => void; iconOnly?: boolean; title: string; icon?: string; @@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'tabClick', key: string); + (ev: 'tabClick', key: string): void; }>(); const tab = defineModel<T['key']>('tab'); @@ -100,7 +100,7 @@ function onTabMousedown(selectedTab: Tab, ev: MouseEvent): void { } } -function onTabClick(t: Tab, ev: MouseEvent): void { +function onTabClick(t: Tab, ev: PointerEvent): void { emit('tabClick', t.key); if (t.onClick) { diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue index 8b7460f3a3..5cd2113e59 100644 --- a/packages/frontend/src/components/MkTagItem.vue +++ b/packages/frontend/src/components/MkTagItem.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root" @click="(ev) => emit('click', ev)"> <span v-if="iconClass" :class="[$style.icon, iconClass]"></span> - <span :class="$style.content">{{ content }}</span> + <span>{{ content }}</span> <MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)"> <span :class="[$style.exButtonIcon, exButtonIconClass]"></span> </MkButton> @@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only import MkButton from '@/components/MkButton.vue'; const emit = defineEmits<{ - (ev: 'click', payload: MouseEvent): void; - (ev: 'exButtonClick', payload: MouseEvent): void; + (ev: 'click', payload: PointerEvent): void; + (ev: 'exButtonClick', payload: PointerEvent): void; }>(); defineProps<{ diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 407ac33add..fe4f7b7aaf 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -63,10 +63,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'change', _ev: InputEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter'): void; (ev: 'update:modelValue', value: string): void; + (ev: 'savingStateChange', saved: boolean, invalid: boolean): void; }>(); const { modelValue, autofocus } = toRefs(props); @@ -79,12 +80,16 @@ const inputEl = useTemplateRef('inputEl'); const preview = ref(false); let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value?.focus(); -const onInput = (ev) => { +function focus() { + inputEl.value?.focus(); +} + +function onInput(ev: InputEvent) { changed.value = true; emit('change', ev); -}; -const onKeydown = (ev: KeyboardEvent) => { +} + +function onKeydown(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; emit('keydown', ev); @@ -102,12 +107,12 @@ const onKeydown = (ev: KeyboardEvent) => { }); ev.preventDefault(); } -}; +} -const updated = () => { +function updated() { changed.value = false; emit('update:modelValue', v.value ?? ''); -}; +} const debouncedUpdated = debounce(1000, updated); @@ -127,6 +132,10 @@ watch(v, () => { invalid.value = inputEl.value?.validity.badInput ?? true; }); +watch([changed, invalid], ([newChanged, newInvalid]) => { + emit('savingStateChange', newChanged, newInvalid); +}, { immediate: true }); + onMounted(() => { nextTick(() => { if (autofocus.value) { diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 42cb6f1e82..8d51e1fa87 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -33,12 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> </div> <div class="_gaps_s"> - <MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch> + <MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind as keyof typeof permissionSwitches]">{{ i18n.ts._permissions[kind as keyof typeof permissionSwitches] }}</MkSwitch> </div> <div v-if="iAmAdmin" :class="$style.adminPermissions"> <div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div> <div class="_gaps_s"> - <MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch> + <MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind as keyof typeof permissionSwitchesForAdmin]">{{ i18n.ts._permissions[kind as keyof typeof permissionSwitchesForAdmin] }}</MkSwitch> </div> </div> </div> @@ -102,8 +102,8 @@ function ok(): void { emit('done', { name: name.value, permissions: [ - ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]), - ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), + ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p as (typeof Misskey.permissions)[number]]), + ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p as (typeof Misskey.permissions)[number]]) : []), ], }); dialog.value?.close(); @@ -111,22 +111,22 @@ function ok(): void { function disableAll(): void { for (const p in permissionSwitches.value) { - permissionSwitches.value[p] = false; + permissionSwitches.value[p as (typeof Misskey.permissions)[number]] = false; } if (iAmAdmin) { for (const p in permissionSwitchesForAdmin.value) { - permissionSwitchesForAdmin.value[p] = false; + permissionSwitchesForAdmin.value[p as (typeof Misskey.permissions)[number]] = false; } } } function enableAll(): void { for (const p in permissionSwitches.value) { - permissionSwitches.value[p] = true; + permissionSwitches.value[p as (typeof Misskey.permissions)[number]] = true; } if (iAmAdmin) { for (const p in permissionSwitchesForAdmin.value) { - permissionSwitchesForAdmin.value[p] = true; + permissionSwitchesForAdmin.value[p as (typeof Misskey.permissions)[number]] = true; } } } diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index aa041c88e5..08a3f02f65 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -71,7 +71,7 @@ function setPosition() { el.value.style.top = data.top + 'px'; } -let loopHandler; +let loopHandler: number | null = null; onMounted(() => { nextTick(() => { @@ -87,7 +87,7 @@ onMounted(() => { }); onUnmounted(() => { - window.cancelAnimationFrame(loopHandler); + if (loopHandler != null) window.cancelAnimationFrame(loopHandler); }); </script> diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index b77e67e9c6..3ab2c5f0d4 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -74,7 +74,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ }); const onceReacted = ref<boolean>(false); -function addReaction(emoji) { +function addReaction(emoji: string) { onceReacted.value = true; emit('reacted'); doNotification(emoji); @@ -96,7 +96,7 @@ function doNotification(emoji: string): void { globalEvents.emit('clientNotification', notification); } -function removeReaction(emoji) { +function removeReaction(emoji: string) { delete exampleNote.reactions[emoji]; exampleNote.myReaction = undefined; } diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index eba8e5472c..09cf595eab 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> +<MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> @@ -26,6 +26,10 @@ import { confetti } from '@/utility/confetti.js'; const modal = useTemplateRef('modal'); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const isBeta = version.includes('-beta') || version.includes('-alpha') || version.includes('-rc'); function whatIsNew() { diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index 8849fa447d..69de56d45c 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -166,17 +166,17 @@ async function done() { dialog.value?.close(); } -async function chooseFile(ev: MouseEvent) { +async function chooseFile(ev: PointerEvent) { const newFiles = await os.chooseFileFromPc({ multiple: true }); uploader.addFiles(newFiles); } -function showPerItemMenu(item: UploaderItem, ev: MouseEvent) { +function showPerItemMenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { +function showPerItemMenuViaContextmenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.contextMenu(menu, ev); } diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue index f31c717ad5..51f7ac2d09 100644 --- a/packages/frontend/src/components/MkUploaderItems.vue +++ b/packages/frontend/src/components/MkUploaderItems.vue @@ -57,18 +57,18 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'showMenu', item: UploaderItem, event: MouseEvent): void; - (ev: 'showMenuViaContextmenu', item: UploaderItem, event: MouseEvent): void; + (ev: 'showMenu', item: UploaderItem, event: PointerEvent): void; + (ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void; }>(); -function onContextmenu(item: UploaderItem, ev: MouseEvent) { +function onContextmenu(item: UploaderItem, ev: PointerEvent) { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; emit('showMenuViaContextmenu', item, ev); } -function onThumbnailClick(item: UploaderItem, ev: MouseEvent) { +function onThumbnailClick(item: UploaderItem, ev: PointerEvent) { // TODO: preview when item is image } </script> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 8ec48dcc3f..5e16460104 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -22,18 +22,26 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTextarea v-model="text"> <template #label>{{ i18n.ts.text }}</template> </MkTextarea> - <MkRadios v-model="icon"> + <MkRadios + v-model="icon" + :options="[ + { value: 'info', icon: 'ti ti-info-circle' }, + { value: 'warning', icon: 'ti ti-alert-triangle', iconStyle: 'color: var(--MI_THEME-warn);' }, + { value: 'error', icon: 'ti ti-circle-x', iconStyle: 'color: var(--MI_THEME-error);' }, + { value: 'success', icon: 'ti ti-check', iconStyle: 'color: var(--MI_THEME-success);' }, + ]" + > <template #label>{{ i18n.ts.icon }}</template> - <option value="info"><i class="ti ti-info-circle"></i></option> - <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i></option> - <option value="error"><i class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i></option> - <option value="success"><i class="ti ti-check" style="color: var(--MI_THEME-success);"></i></option> </MkRadios> - <MkRadios v-model="display"> + <MkRadios + v-model="display" + :options="[ + { value: 'normal', label: i18n.ts.normal }, + { value: 'banner', label: i18n.ts.banner }, + { value: 'dialog', label: i18n.ts.dialog }, + ]" + > <template #label>{{ i18n.ts.display }}</template> - <option value="normal">{{ i18n.ts.normal }}</option> - <option value="banner">{{ i18n.ts.banner }}</option> - <option value="dialog">{{ i18n.ts.dialog }}</option> </MkRadios> <MkSwitch v-model="needConfirmationToRead"> {{ i18n.ts._announcement.needConfirmationToRead }} diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index dde2efd8ee..1fd43bd6e4 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar :class="$style.avatar" :user="user" indicator/> <div :class="$style.body"> <span :class="$style.name"><MkUserName :user="user"/></span> - <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span> + <span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span> </div> <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> </div> diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index f47d9b56dc..8ce929fff3 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -27,7 +27,7 @@ const props = withDefaults(defineProps<{ noGap?: boolean; extractor?: ExtractorFunction<P, Misskey.entities.UserDetailed>; }>(), { - extractor: (item) => item, + extractor: (item: any) => item as Misskey.entities.UserDetailed, }); </script> diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index f794899281..9f196ac2c1 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -90,7 +90,7 @@ const top = ref(0); const left = ref(0); const error = ref(false); -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { if (user.value == null) return; const { menu, cleanup } = getUserMenu(user.value); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 4e96eff82e..95449dd0eb 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -66,7 +66,7 @@ watch(description, () => { }); }); -async function setAvatar(ev) { +async function setAvatar(ev: PointerEvent) { const files = await os.chooseFileFromPc({ multiple: false }); const file = files[0]; diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 88b934bb58..361fda0c24 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.itemDescription">{{ i18n.ts._visibility.followersDescription }}</span> </div> </button> - <button key="specified" :disabled="localOnly" class="_button" :class="[$style.item, { [$style.active]: v === 'specified' }]" data-index="4" @click="choose('specified')"> + <button key="specified" class="_button" :class="[$style.item, { [$style.active]: v === 'specified' }]" data-index="4" @click="choose('specified')"> <div :class="$style.icon"><i class="ti ti-mail"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.specified }}</span> @@ -52,7 +52,6 @@ const modal = useTemplateRef('modal'); const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; isSilenced: boolean; - localOnly: boolean; anchorElement?: HTMLElement | null; isReplyVisibilitySpecified?: boolean; }>(), { diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index 6aaee76565..6513ca385d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -46,7 +46,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 8bef225de5..2ce1912b86 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -94,7 +94,7 @@ function signup() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { openInstanceMenu(ev); } </script> diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 820cf05e1f..18f2b3e189 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -26,8 +26,8 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'done'); - (ev: 'closed'); + (ev: 'done'): void; + (ev: 'closed'): void; }>(); function done() { diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index 154b3ffc27..8e5bb6221d 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -387,7 +387,7 @@ onMounted(async () => { } }); -function chooseFile(ev: MouseEvent) { +function chooseFile(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 6cd2111598..cadf9ba522 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -16,50 +16,49 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template> - <div :class="$style.root"> - <div :class="$style.container"> - <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> - <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> - <div :class="$style.previewContainer"> - <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> - <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> - <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> - </div> + <MkPreviewWithControls> + <template #preview> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> </div> </div> - <div :class="$style.controls"> - <div class="_spacer _gaps"> - <div class="_gaps_s"> - <MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false"> - <template #label> - <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> - <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> - <div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div> - <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> - <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> - <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> - </template> - <template #footer> - <div class="_buttons"> - <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> - <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> - <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> - </div> - </template> + </template> - <XLayer - v-model:layer="layers[i]" - ></XLayer> - </MkFolder> + <template #controls> + <div class="_spacer _gaps"> + <div class="_gaps_s"> + <MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false"> + <template #label> + <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> + <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> + <div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div> + <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> + <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> + <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> + </template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> - <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> - </div> + <XLayer + v-model:layer="layers[i]" + ></XLayer> + </MkFolder> + + <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> </div> </div> - </div> - </div> + </template> + </MkPreviewWithControls> </MkModalWindow> </template> @@ -69,6 +68,7 @@ import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/Water import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -350,7 +350,7 @@ async function save() { } } -function addLayer(ev: MouseEvent) { +function addLayer(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts._watermarkEditor.text, action: () => { @@ -411,33 +411,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { </script> <style module> -.root { - container-type: inline-size; - height: 100%; -} - -.container { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.preview { - position: relative; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .previewContainer { display: flex; flex-direction: column; @@ -474,16 +447,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { } } -.previewSpinner { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - user-select: none; - -webkit-user-drag: none; -} - .previewCanvas { position: absolute; top: 0; @@ -494,15 +457,4 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { box-sizing: border-box; object-fit: contain; } - -.controls { - overflow-y: scroll; -} - -@container (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkWidgetSettingsDialog.vue b/packages/frontend/src/components/MkWidgetSettingsDialog.vue new file mode 100644 index 0000000000..292b4010ff --- /dev/null +++ b/packages/frontend/src/components/MkWidgetSettingsDialog.vue @@ -0,0 +1,174 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + :okButtonDisabled="!canSave" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-icons"></i> {{ i18n.ts._widgets[widgetName] ?? widgetName }}</template> + + <MkPreviewWithControls> + <template #preview> + <div :class="$style.previewWrapper"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + + <div ref="resizerRootEl" :class="$style.previewResizerRoot" inert> + <div + ref="resizerEl" + :class="$style.previewResizer" + :style="{ transform: widgetStyle }" + > + <component + :is="`widget-${widgetName}`" + :widget="{ name: widgetName, id: '__PREVIEW__', data: settings }" + ></component> + </div> + </div> + </div> + </template> + + <template #controls> + <div class="_spacer"> + <MkForm v-model="settings" :form="form" @canSaveStateChange="onCanSaveStateChanged"/> + </div> + </template> + </MkPreviewWithControls> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { useTemplateRef, ref, computed, onBeforeUnmount, onMounted } from 'vue'; +import MkPreviewWithControls from './MkPreviewWithControls.vue'; +import type { Form } from '@/utility/form.js'; +import type { WidgetName } from '@/widgets/index.js'; +import { deepClone } from '@/utility/clone.js'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkForm from '@/components/MkForm.vue'; + +const props = defineProps<{ + widgetName: WidgetName; + form: Form; + currentSettings: Record<string, any>; +}>(); + +const emit = defineEmits<{ + (ev: 'saved', settings: Record<string, any>): void; + (ev: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +const settings = ref<Record<string, any>>(deepClone(props.currentSettings)); + +const canSave = ref(true); + +function onCanSaveStateChanged(newCanSave: boolean) { + canSave.value = newCanSave; +} + +function save() { + if (!canSave.value) return; + emit('saved', deepClone(settings.value)); + dialog.value?.close(); +} + +function cancel() { + emit('canceled'); + dialog.value?.close(); +} + +//#region プレビューのリサイズ +const resizerRootEl = useTemplateRef('resizerRootEl'); +const resizerEl = useTemplateRef('resizerEl'); +const widgetHeight = ref(0); +const widgetScale = ref(1); +const widgetStyle = computed(() => { + return `translate(-50%, -50%) scale(${widgetScale.value})`; +}); +const ro1 = new ResizeObserver(() => { + widgetHeight.value = resizerEl.value!.clientHeight; + calcScale(); +}); +const ro2 = new ResizeObserver(() => { + calcScale(); +}); + +function calcScale() { + if (!resizerRootEl.value) return; + const previewWidth = resizerRootEl.value.clientWidth - 40; // 左右の余白 20pxずつ + const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下の余白 20pxずつ + const widgetWidth = 280; + const scale = Math.min(previewWidth / widgetWidth, previewHeight / widgetHeight.value, 1); // 拡大はしないので1を上限に + widgetScale.value = scale; +} + +onMounted(() => { + if (resizerEl.value) { + ro1.observe(resizerEl.value); + } + if (resizerRootEl.value) { + ro2.observe(resizerRootEl.value); + } + calcScale(); +}); + +onBeforeUnmount(() => { + ro1.disconnect(); + ro2.disconnect(); +}); +//#endregion +</script> + +<style module> +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewWrapper { + display: flex; + flex-direction: column; + height: 100%; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewResizerRoot { + position: relative; + flex: 1 0; +} + +.previewResizer { + position: absolute; + container-type: inline-size; + top: 50%; + left: 50%; + width: 280px; +} +</style> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index cf7c2cda80..a27613c24c 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div :class="$style.root" class="_gaps_s"> <template v-if="edit"> <header :class="$style.editHeader"> <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> @@ -13,27 +13,23 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> </header> - <Sortable + <MkDraggable :modelValue="props.widgets" - itemKey="id" - handle=".handle" - :animation="150" - :group="{ name: 'SortableMkWidgets' }" - :class="$style.editEditing" + direction="vertical" + withGaps + group="MkWidgets" @update:modelValue="v => emit('updateWidgets', v)" > - <template #item="{element}"> + <template #default="{ item }"> <div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container> - <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> - <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> - <div class="handle"> - <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style.customizeContainerHandleWidget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> - </div> + <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(item.id)"><i class="ti ti-settings"></i></button> + <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(item)"><i class="ti ti-x"></i></button> + <component :is="`widget-${item.name}`" :ref="(el: any) => widgetRefs[item.id] = el" :class="$style.customizeContainerHandleWidget" :widget="item" @updateProps="updateWidget(item.id, $event)"/> </div> </template> - </Sortable> + </MkDraggable> </template> - <component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> + <component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="(el: any) => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> </div> </template> @@ -49,19 +45,19 @@ export type DefaultStoredWidget = { </script> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { computed } from 'vue'; import { isLink } from '@@/js/is-link.js'; +import type { Component } from 'vue'; import { genId } from '@/utility/id.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ widgets: Widget[]; edit: boolean; @@ -69,13 +65,13 @@ const props = defineProps<{ const _widgetDefs = computed(() => { if (instance.federation === 'none') { - return widgetDefs.filter(x => !federationWidgets.includes(x)); + return widgetDefs.filter(x => !federationWidgets.includes(x as any)); } else { return widgetDefs; } }); -const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name))); +const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name as any))); const emit = defineEmits<{ (ev: 'updateWidgets', widgets: Widget[]): void; @@ -85,10 +81,11 @@ const emit = defineEmits<{ (ev: 'exit'): void; }>(); -const widgetRefs = {}; -const configWidget = (id: string) => { +const widgetRefs = {} as Record<string, Component & { configure: () => void }>; + +function configWidget(id: string) { widgetRefs[id].configure(); -}; +} const { model: widgetAdderSelected, @@ -98,7 +95,7 @@ const { initialValue: null, }); -const addWidget = () => { +function addWidget() { if (widgetAdderSelected.value == null) return; emit('addWidget', { @@ -108,23 +105,25 @@ const addWidget = () => { }); widgetAdderSelected.value = null; -}; -const removeWidget = (widget) => { +} + +function removeWidget(widget: Widget) { emit('removeWidget', widget); -}; -const updateWidget = (id: Widget['id'], data: Widget['data']) => { +} + +function updateWidget(id: Widget['id'], data: Widget['data']) { emit('updateWidget', { id, data }); -}; +} -function onContextmenu(widget: Widget, ev: MouseEvent) { +function onContextmenu(widget: Widget, ev: PointerEvent) { const element = ev.target as HTMLElement | null; if (element && isLink(element)) return; - if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes['contenteditable'])) return; + if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes.getNamedItem('contenteditable') != null)) return; if (window.getSelection()?.toString() !== '') return; os.contextMenu([{ type: 'label', - text: i18n.ts._widgets[widget.name], + text: i18n.ts._widgets[widget.name as typeof widgetDefs[number]], }, { icon: 'ti ti-settings', text: i18n.ts.settings, @@ -142,11 +141,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { .widget { contain: content; - margin: var(--MI-margin) 0; - - &:first-of-type { - margin-top: 0; - } } .edit { @@ -158,10 +152,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { padding: 4px; } } - - &Editing { - min-height: 100px; - } } .customizeContainer { diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index e5ac791d0b..c79bf44794 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button> </template> </span> - <span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> + <span :class="$style.headerTitle" @pointerdown.prevent="onHeaderPointerdown"> <slot name="header"></slot> </span> <span :class="$style.headerRight"> @@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <template v-if="canResize && !minimized"> - <div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div> - <div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div> - <div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div> - <div :class="$style.handleLeft" @mousedown.prevent="onLeftHandleMousedown"></div> - <div :class="$style.handleTopLeft" @mousedown.prevent="onTopLeftHandleMousedown"></div> - <div :class="$style.handleTopRight" @mousedown.prevent="onTopRightHandleMousedown"></div> - <div :class="$style.handleBottomRight" @mousedown.prevent="onBottomRightHandleMousedown"></div> - <div :class="$style.handleBottomLeft" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + <div :class="$style.handleTop" @pointerdown.prevent="onTopHandlePointerdown"></div> + <div :class="$style.handleRight" @pointerdown.prevent="onRightHandlePointerdown"></div> + <div :class="$style.handleBottom" @pointerdown.prevent="onBottomHandlePointerdown"></div> + <div :class="$style.handleLeft" @pointerdown.prevent="onLeftHandlePointerdown"></div> + <div :class="$style.handleTopLeft" @pointerdown.prevent="onTopLeftHandlePointerdown"></div> + <div :class="$style.handleTopRight" @pointerdown.prevent="onTopRightHandlePointerdown"></div> + <div :class="$style.handleBottomRight" @pointerdown.prevent="onBottomRightHandlePointerdown"></div> + <div :class="$style.handleBottomLeft" @pointerdown.prevent="onBottomLeftHandlePointerdown"></div> </template> </div> </Transition> @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/utility/contains.js'; +import { elementContains } from '@/utility/element-contains.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -70,20 +70,39 @@ type WindowButton = { const minHeight = 50; const minWidth = 250; -function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) { - window.addEventListener('mousemove', fn); - window.addEventListener('touchmove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - window.addEventListener('touchend', dragClear.bind(null, fn)); +function dragListen(fn: (ev: PointerEvent) => void) { + window.addEventListener('pointermove', fn); + const clear = () => { + dragClear(fn); + }; + window.addEventListener('pointerup', clear, { once: true }); + window.addEventListener('pointercancel', clear, { once: true }); + window.addEventListener('blur', clear, { once: true }); } -function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('touchmove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - window.removeEventListener('touchend', dragClear); +function dragClear(fn: (ev: PointerEvent) => void) { + window.removeEventListener('pointermove', fn); +} + +function capturePointer(evt: PointerEvent) { + const target = evt.currentTarget; + if (!(target instanceof HTMLElement)) return; + if (!target.setPointerCapture) return; + + try { + target.setPointerCapture(evt.pointerId); + } catch { + return; + } + + const release = () => { + if (target.hasPointerCapture(evt.pointerId)) { + target.releasePointerCapture(evt.pointerId); + } + }; + + window.addEventListener('pointerup', release, { once: true }); + window.addEventListener('pointercancel', release, { once: true }); } const props = withDefaults(defineProps<{ @@ -128,7 +147,7 @@ function close() { showing.value = false; } -function onKeydown(evt) { +function onKeydown(evt: KeyboardEvent) { if (evt.which === 27) { // Esc evt.preventDefault(); evt.stopPropagation(); @@ -136,7 +155,7 @@ function onKeydown(evt) { } } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { if (props.contextmenu) { os.contextMenu(props.contextmenu, ev); } @@ -209,15 +228,17 @@ function onDblClick() { } } -function getPositionX(event: MouseEvent | TouchEvent) { - return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0; +function getPositionX(event: PointerEvent) { + return event.clientX; } -function getPositionY(event: MouseEvent | TouchEvent) { - return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0; +function getPositionY(event: PointerEvent) { + return event.clientY; } -function onHeaderMousedown(evt: MouseEvent | TouchEvent) { +function onHeaderPointerdown(evt: PointerEvent) { + capturePointer(evt); + // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 if ('button' in evt && evt.button === 2) return; @@ -240,7 +261,7 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - if (!contains(main, window.document.activeElement)) main.focus(); + if (!elementContains(main, window.document.activeElement)) main.focus(); const position = main.getBoundingClientRect(); @@ -289,7 +310,9 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { } // 上ハンドル掴み時 -function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { +function onTopHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; // どういうわけかnullになることがある if (main == null) return; @@ -317,7 +340,9 @@ function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { } // 右ハンドル掴み時 -function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { +function onRightHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; if (main == null) return; @@ -342,7 +367,9 @@ function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { } // 下ハンドル掴み時 -function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { +function onBottomHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; if (main == null) return; @@ -367,7 +394,9 @@ function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { } // 左ハンドル掴み時 -function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { +function onLeftHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; if (main == null) return; @@ -394,48 +423,48 @@ function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { } // 左上ハンドル掴み時 -function onTopLeftHandleMousedown(evt: MouseEvent | TouchEvent) { - onTopHandleMousedown(evt); - onLeftHandleMousedown(evt); +function onTopLeftHandlePointerdown(evt: PointerEvent) { + onTopHandlePointerdown(evt); + onLeftHandlePointerdown(evt); } // 右上ハンドル掴み時 -function onTopRightHandleMousedown(evt: MouseEvent | TouchEvent) { - onTopHandleMousedown(evt); - onRightHandleMousedown(evt); +function onTopRightHandlePointerdown(evt: PointerEvent) { + onTopHandlePointerdown(evt); + onRightHandlePointerdown(evt); } // 右下ハンドル掴み時 -function onBottomRightHandleMousedown(evt: MouseEvent | TouchEvent) { - onBottomHandleMousedown(evt); - onRightHandleMousedown(evt); +function onBottomRightHandlePointerdown(evt: PointerEvent) { + onBottomHandlePointerdown(evt); + onRightHandlePointerdown(evt); } // 左下ハンドル掴み時 -function onBottomLeftHandleMousedown(evt: MouseEvent | TouchEvent) { - onBottomHandleMousedown(evt); - onLeftHandleMousedown(evt); +function onBottomLeftHandlePointerdown(evt: PointerEvent) { + onBottomHandlePointerdown(evt); + onLeftHandlePointerdown(evt); } // 高さを適用 -function applyTransformHeight(height) { +function applyTransformHeight(height: number) { if (height > window.innerHeight) height = window.innerHeight; if (rootEl.value) rootEl.value.style.height = height + 'px'; } // 幅を適用 -function applyTransformWidth(width) { +function applyTransformWidth(width: number) { if (width > window.innerWidth) width = window.innerWidth; if (rootEl.value) rootEl.value.style.width = width + 'px'; } // Y座標を適用 -function applyTransformTop(top) { +function applyTransformTop(top: number) { if (rootEl.value) rootEl.value.style.top = top + 'px'; } // X座標を適用 -function applyTransformLeft(left) { +function applyTransformLeft(left: number) { if (rootEl.value) rootEl.value.style.left = left + 'px'; } @@ -566,6 +595,7 @@ defineExpose({ overflow: hidden; text-overflow: ellipsis; cursor: move; + touch-action: none; } .content { @@ -579,6 +609,7 @@ $handleSize: 8px; .handle { position: absolute; + touch-action: none; } .handleTop { diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 2375bcc9eb..20c4475779 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkWindow :initialWidth="640" :initialHeight="402" :canResize="true" :closeButton="true"> +<MkWindow :initialWidth="640" :initialHeight="402" :canResize="true" :closeButton="true" @closed="emit('closed')"> <template #header> <i class="icon ti ti-brand-youtube" style="margin-right: 0.5em;"></i> <span>{{ title ?? 'YouTube' }}</span> @@ -34,6 +34,10 @@ const props = defineProps<{ url: string; }>(); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue index 9866e50958..1fad1ee9e6 100644 --- a/packages/frontend/src/components/global/I18n.vue +++ b/packages/frontend/src/components/global/I18n.vue @@ -46,6 +46,6 @@ const parsed = computed(() => { }); const render = () => { - return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); + return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : (slots as any)[x.arg]())); }; </script> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 99693a4c00..7d2908d4be 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -48,7 +48,7 @@ const active = computed(() => { return resolved.route.name === router.currentRoute.value.name; }); -function onContextmenu(ev) { +function onContextmenu(ev: PointerEvent) { const selection = window.getSelection(); if (selection && selection.toString() !== '') return; os.contextMenu([{ @@ -85,7 +85,7 @@ function openWindow() { os.pageWindow(props.to); } -function nav(ev: MouseEvent) { +function nav(ev: PointerEvent) { // 制御キーとの組み合わせは無視(shiftを除く) if (ev.metaKey || ev.altKey || ev.ctrlKey) return; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index e7208ed574..b413fef3b8 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -11,16 +11,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="user.isCat" :class="[$style.ears]"> <div :class="$style.earLeft"> <div v-if="false" :class="$style.layer"> - <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> - <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> - <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div> </div> </div> <div :class="$style.earRight"> <div v-if="false" :class="$style.layer"> - <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> - <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> - <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div> </div> </div> </div> @@ -77,7 +77,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'click', v: MouseEvent): void; + (ev: 'click', v: PointerEvent): void; }>(); const showDecoration = props.forceShowDecoration || prefer.s.showAvatarDecorations; @@ -91,7 +91,7 @@ const url = computed(() => { return props.user.avatarUrl; }); -function onClick(ev: MouseEvent): void { +function onClick(ev: PointerEvent): void { if (props.link) return; emit('click', ev); } diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue index 473d444c16..baa8d783f1 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.vue +++ b/packages/frontend/src/components/global/MkCondensedLine.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <span :class="$style.container"> <span ref="content" :class="$style.content" :style="{ maxWidth: `${100 / minScale}%` }"> - <slot/> + <slot></slot> </span> </span> </template> @@ -23,8 +23,8 @@ const observer = new ResizeObserver((entries) => { transform: string; }[] = []; for (const entry of entries) { - const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement; - const props: Required<Props> = content[contentSymbol]; + const content = ((entry.target as any)[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement; + const props: Required<Props> = (content as any)[contentSymbol]; const container = content.parentElement as HTMLSpanElement; const contentWidth = content.getBoundingClientRect().width; const containerWidth = container.getBoundingClientRect().width; @@ -46,15 +46,15 @@ const props = withDefaults(defineProps<Props>(), { const content = ref<HTMLSpanElement>(); watch(content, (value, oldValue) => { - if (oldValue) { - delete oldValue[contentSymbol]; + if (oldValue != null) { + delete (oldValue as any)[contentSymbol]; observer.unobserve(oldValue); if (oldValue.parentElement) { observer.unobserve(oldValue.parentElement); } } - if (value) { - value[contentSymbol] = props; + if (value != null) { + (value as any)[contentSymbol] = props; observer.observe(value); if (value.parentElement) { observer.observe(value.parentElement); diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 31c358eee7..9a171876a0 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -102,7 +102,7 @@ const url = computed(() => { const alt = computed(() => `:${customEmojiName.value}:`); const errored = ref(url.value == null); -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { if (props.menu) { const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index 792f9c7d6f..686720cec2 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -67,7 +67,7 @@ function unmute() { }); } -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { if (props.menu) { const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 3ad2fda0ee..706ea07417 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -233,7 +233,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven if (!useAnim) { return genEl(token.children, scale); } - return h(MkSparkle, {}, genEl(token.children, scale)); + return h(MkSparkle, {}, { default: () => genEl(token.children, scale) }); } case 'rotate': { const degrees = safeParseFloat(token.props.args.deg) ?? 90; @@ -319,7 +319,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven ]); } case 'clickable': { - return h('span', { onClick(ev: MouseEvent): void { + return h('span', { onClick(ev: PointerEvent): void { ev.stopPropagation(); ev.preventDefault(); const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; @@ -363,7 +363,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven url: token.props.url, rel: 'nofollow noopener', navigationBehavior: props.linkNavigationBehavior, - }, genEl(token.children, scale, true))]; + }, { default: () => genEl(token.children, scale, true) })]; } case 'mention': { @@ -381,7 +381,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--MI_THEME-hashtag);', behavior: props.linkNavigationBehavior, - }, `#${token.props.hashtag}`)]; + }, { default: () => `#${token.props.hashtag}` })]; } case 'blockCode': { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 1ef75281fd..857fd3d8b4 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab = { key: string; - onClick?: (ev: MouseEvent) => void; + onClick?: (ev: PointerEvent) => void; iconOnly?: boolean; title: string; icon?: string; @@ -70,8 +70,8 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'update:tab', key: string); - (ev: 'tabClick', key: string); + (ev: 'update:tab', key: string): void; + (ev: 'tabClick', key: string): void; }>(); const el = useTemplateRef('el'); @@ -96,7 +96,7 @@ function onTabMousedown(tab: Tab, ev: MouseEvent): void { } } -function onTabClick(t: Tab, ev: MouseEvent): void { +function onTabClick(t: Tab, ev: PointerEvent): void { emit('tabClick', t.key); if (t.onClick) { diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 2f6dfed221..e8c93b7092 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-if="show" ref="el" :class="[$style.root]"> <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> - <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> + <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu"> <MkAvatar :class="$style.avatar" :user="$i"/> </div> - <div v-else-if="!thin_ && narrow && !hideTitle" :class="[$style.buttons, $style.buttonsLeft]"></div> + <div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttons"></div> <template v-if="pageMetadata"> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/> </template> - <div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="[$style.buttons, $style.buttonsRight]"> + <div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttons"> <template v-for="action in actions"> <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> </template> @@ -61,7 +61,6 @@ export type PageHeaderProps = { import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue'; import { scrollToTop } from '@@/js/scroll.js'; import XTabs from './MkPageHeader.tabs.vue'; -import { globalEvents } from '@/events.js'; import { getAccountMenu } from '@/accounts.js'; import { $i } from '@/i.js'; import { DI } from '@/di.js'; @@ -72,7 +71,7 @@ const props = withDefaults(defineProps<PageHeaderProps>(), { }); const emit = defineEmits<{ - (ev: 'update:tab', key: string); + (ev: 'update:tab', key: string): void; }>(); //const viewId = inject(DI.viewId); @@ -100,7 +99,7 @@ const top = () => { } }; -async function openAccountMenu(ev: MouseEvent) { +async function openAccountMenu(ev: PointerEvent) { const menuItems = await getAccountMenu({ withExtraOperation: true, }); diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue index 2071859e57..0dfb23782d 100644 --- a/packages/frontend/src/components/global/MkResult.vue +++ b/packages/frontend/src/components/global/MkResult.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> - <div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps"> + <div :class="$style.root" class="_gaps"> <img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/> <MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/> <img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/> diff --git a/packages/frontend/src/components/global/MkTip.vue b/packages/frontend/src/components/global/MkTip.vue index 231957a232..1827c16c89 100644 --- a/packages/frontend/src/components/global/MkTip.vue +++ b/packages/frontend/src/components/global/MkTip.vue @@ -32,7 +32,7 @@ function _closeTip() { closeTip(props.k); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu([{ icon: 'ti ti-bulb-off', text: i18n.ts.hideAllTips, diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index aac87b7669..a11b291418 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> +<div ref="rootEl" :class="reversed ? '_pageScrollableReversed' : '_pageScrollable'"> <MkStickyContainer> <template #header> <MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/> diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue index d52dd9b89d..689954189d 100644 --- a/packages/frontend/src/components/global/StackingRouterView.vue +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -171,12 +171,6 @@ router.useListener('replace', ({ fullPath }) => { width: 100%; height: 100%; } - - .tabContent { - position: relative; - width: 100%; - height: 100%; - } } &:not(:first-child) { @@ -209,13 +203,17 @@ router.useListener('replace', ({ fullPath }) => { .tabContent { flex: 1; - width: 100%; - height: 100%; - background: var(--MI_THEME-bg); } } } +.tabContent { + position: relative; + width: 100%; + height: 100%; + background: var(--MI_THEME-bg); +} + .tabMenu { position: relative; margin-left: auto; diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 6f1dae8398..8745146ccf 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -188,7 +188,7 @@ function onCellKeyDown(ev: KeyboardEvent) { } } -function onInputText(ev: Event) { +function onInputText(ev: InputEvent) { editingValue.value = (ev.target as HTMLInputElement).value; } diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 96d9e35773..097a91bad5 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -715,7 +715,7 @@ function onMouseUp(ev: MouseEvent) { } } -function onContextMenu(ev: MouseEvent) { +function onContextMenu(ev: PointerEvent) { const cellAddress = getCellAddress(ev.target as HTMLElement); if (_DEV_) { console.log(`[grid][context-menu] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); |