summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-02-16 21:42:35 +0000
committerHazelnoot <acomputerdog@gmail.com>2025-02-16 21:42:35 +0000
commit2d7918a9b74a1c049c2e520b0331ba6f161c1a16 (patch)
treec2e30ecca540b187eee0659afa249bad51b45fe3 /packages/frontend/src/components
parentmerge: fill `myReaction` in more cases - may fix #944 (!907) (diff)
parentMerge branch 'develop' into merge/2024-02-03 (diff)
downloadsharkey-2d7918a9b74a1c049c2e520b0331ba6f161c1a16.tar.gz
sharkey-2d7918a9b74a1c049c2e520b0331ba6f161c1a16.tar.bz2
sharkey-2d7918a9b74a1c049c2e520b0331ba6f161c1a16.zip
merge: Merge upstream 2025.2.0 (!886)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886 Approved-by: Marie <github@yuugi.dev> Approved-by: Amber Null <puppygirlhornyposting@gmail.com>
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkAsUi.vue19
-rw-r--r--packages/frontend/src/components/MkCaptcha.vue64
-rw-r--r--packages/frontend/src/components/MkChannelPreview.vue2
-rw-r--r--packages/frontend/src/components/MkContainer.vue10
-rw-r--r--packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue2
-rw-r--r--packages/frontend/src/components/MkDriveFileThumbnail.vue28
-rw-r--r--packages/frontend/src/components/MkFolder.vue6
-rw-r--r--packages/frontend/src/components/MkFormFooter.vue9
-rw-r--r--packages/frontend/src/components/MkInstanceStats.vue18
-rw-r--r--packages/frontend/src/components/MkInstanceTicker.vue41
-rw-r--r--packages/frontend/src/components/MkMention.vue2
-rw-r--r--packages/frontend/src/components/MkModal.vue29
-rw-r--r--packages/frontend/src/components/MkNote.vue32
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue4
-rw-r--r--packages/frontend/src/components/MkNoteMediaGrid.vue109
-rw-r--r--packages/frontend/src/components/MkNotes.vue2
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue2
-rw-r--r--packages/frontend/src/components/MkPagingButtons.vue124
-rw-r--r--packages/frontend/src/components/MkPoll.vue4
-rw-r--r--packages/frontend/src/components/MkPostForm.vue32
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue8
-rw-r--r--packages/frontend/src/components/MkRemoteEmojiEditDialog.vue132
-rw-r--r--packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts106
-rw-r--r--packages/frontend/src/components/MkRoleSelectDialog.vue200
-rw-r--r--packages/frontend/src/components/MkSignin.input.vue2
-rw-r--r--packages/frontend/src/components/MkSignin.vue1
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue2
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.vue6
-rw-r--r--packages/frontend/src/components/MkSortOrderEditor.define.ts11
-rw-r--r--packages/frontend/src/components/MkSortOrderEditor.vue118
-rw-r--r--packages/frontend/src/components/MkSparkle.vue53
-rw-r--r--packages/frontend/src/components/MkSubNoteContent.vue4
-rw-r--r--packages/frontend/src/components/MkSuperMenu.vue2
-rw-r--r--packages/frontend/src/components/MkTagItem.stories.impl.ts70
-rw-r--r--packages/frontend/src/components/MkTagItem.vue76
-rw-r--r--packages/frontend/src/components/MkUserSelectDialog.vue12
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue6
-rw-r--r--packages/frontend/src/components/MkWidgets.vue19
-rw-r--r--packages/frontend/src/components/SkInstanceTicker.vue48
-rw-r--r--packages/frontend/src/components/SkNote.vue30
-rw-r--r--packages/frontend/src/components/SkNoteDetailed.vue4
-rw-r--r--packages/frontend/src/components/SkNoteHeader.vue7
-rw-r--r--packages/frontend/src/components/global/MkAcct.vue2
-rw-r--r--packages/frontend/src/components/global/MkMfm.ts4
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue12
-rw-r--r--packages/frontend/src/components/global/MkUrl.vue2
-rw-r--r--packages/frontend/src/components/grid/MkCellTooltip.vue35
-rw-r--r--packages/frontend/src/components/grid/MkDataCell.vue418
-rw-r--r--packages/frontend/src/components/grid/MkDataRow.vue72
-rw-r--r--packages/frontend/src/components/grid/MkGrid.stories.impl.ts223
-rw-r--r--packages/frontend/src/components/grid/MkGrid.vue1374
-rw-r--r--packages/frontend/src/components/grid/MkHeaderCell.vue216
-rw-r--r--packages/frontend/src/components/grid/MkHeaderRow.vue60
-rw-r--r--packages/frontend/src/components/grid/MkNumberCell.vue61
-rw-r--r--packages/frontend/src/components/grid/cell-validators.ts110
-rw-r--r--packages/frontend/src/components/grid/cell.ts88
-rw-r--r--packages/frontend/src/components/grid/column.ts53
-rw-r--r--packages/frontend/src/components/grid/grid-event.ts46
-rw-r--r--packages/frontend/src/components/grid/grid-utils.ts215
-rw-r--r--packages/frontend/src/components/grid/grid.ts49
-rw-r--r--packages/frontend/src/components/grid/row.ts68
-rw-r--r--packages/frontend/src/components/hook/useLoading.ts52
62 files changed, 4449 insertions, 167 deletions
diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue
index c28dbc7ffa..564d1fe7e3 100644
--- a/packages/frontend/src/components/MkAsUi.vue
+++ b/packages/frontend/src/components/MkAsUi.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
- <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="c.default ?? null" @update:modelValue="c.onChange">
+ <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
@@ -77,8 +77,8 @@ import MkPostForm from '@/components/MkPostForm.vue';
const props = withDefaults(defineProps<{
component: AsUiComponent;
components: Ref<AsUiComponent>[];
- size: 'small' | 'medium' | 'large';
- align: 'left' | 'center' | 'right';
+ size?: 'small' | 'medium' | 'large';
+ align?: 'left' | 'center' | 'right';
}>(), {
size: 'medium',
align: 'left',
@@ -86,7 +86,7 @@ const props = withDefaults(defineProps<{
const c = props.component;
-function g(id) {
+function g(id: string) {
const v = props.components.find(x => x.value.id === id)?.value;
if (v) return v;
@@ -122,13 +122,22 @@ const containerStyle = computed(() => {
const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false);
-function onSwitchUpdate(v) {
+function onSwitchUpdate(v: boolean) {
valueForSwitch.value = v;
if ('onChange' in c && c.onChange) {
c.onChange(v as never);
}
}
+const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null);
+
+function onSelectUpdate(v) {
+ valueForSelect.value = v;
+ if ('onChange' in c && c.onChange) {
+ c.onChange(v as never);
+ }
+}
+
function openPostForm() {
const form = (c as AsUiPostFormButton).form;
if (!form) return;
diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index e9493edbd1..aeed90722f 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount
import { defaultStore } from '@/store.js';
// APIs provided by Captcha services
+// see: https://docs.hcaptcha.com/configuration/#javascript-api
+// see: https://developers.google.com/recaptcha/docs/display?hl=ja
+// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget
export type Captcha = {
render(container: string | Node, options: {
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
@@ -56,6 +59,7 @@ declare global {
const props = defineProps<{
provider: CaptchaProvider;
sitekey: string | null; // null will show error on request
+ secretKey?: string | null;
instanceUrl?: string | null;
modelValue?: string | null;
}>();
@@ -67,7 +71,7 @@ const emit = defineEmits<{
const available = ref(false);
const captchaEl = shallowRef<HTMLDivElement | undefined>();
-
+const captchaWidgetId = ref<string | undefined>(undefined);
const testcaptchaInput = ref('');
const testcaptchaPassed = ref(false);
@@ -99,6 +103,15 @@ const scriptId = computed(() => `script-${props.provider}`);
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
+watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
+ // 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない
+ if (available.value) {
+ callback(undefined);
+ clearWidget();
+ await requestRender();
+ }
+});
+
if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
available.value = true;
} else if (src.value !== null) {
@@ -111,14 +124,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha')
}
function reset() {
- if (captcha.value.reset) captcha.value.reset();
+ if (captcha.value.reset && captchaWidgetId.value !== undefined) {
+ try {
+ captcha.value.reset(captchaWidgetId.value);
+ } catch (error: unknown) {
+ // ignore
+ if (_DEV_) console.warn(error);
+ }
+ }
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
}
+function remove() {
+ if (captcha.value.remove && captchaWidgetId.value) {
+ try {
+ if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
+ captcha.value.remove(captchaWidgetId.value);
+ } catch (error: unknown) {
+ // ignore
+ if (_DEV_) console.warn(error);
+ }
+ }
+}
+
async function requestRender() {
- if (captcha.value.render && captchaEl.value instanceof Element) {
- captcha.value.render(captchaEl.value, {
+ if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
+ // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
+ // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので)
+ const elem = document.createElement('div');
+ captchaEl.value.appendChild(elem);
+
+ captchaWidgetId.value = captcha.value.render(elem, {
sitekey: props.sitekey,
theme: defaultStore.state.darkMode ? 'dark' : 'light',
callback: callback,
@@ -146,6 +183,23 @@ async function requestRender() {
}
}
+function clearWidget() {
+ if (props.provider === 'mcaptcha') {
+ const container = document.getElementById('mcaptcha__widget-container');
+ if (container) {
+ container.innerHTML = '';
+ }
+ } else {
+ reset();
+ remove();
+
+ if (captchaEl.value) {
+ // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止
+ captchaEl.value.innerHTML = '';
+ }
+ }
+}
+
function callback(response?: string) {
emit('update:modelValue', typeof response === 'string' ? response : null);
}
@@ -178,7 +232,7 @@ onUnmounted(() => {
});
onBeforeUnmount(() => {
- reset();
+ clearWidget();
});
defineExpose({
diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue
index e036fec528..7ff9da1ced 100644
--- a/packages/frontend/src/components/MkChannelPreview.vue
+++ b/packages/frontend/src/components/MkChannelPreview.vue
@@ -125,7 +125,9 @@ const bannerStyle = computed(() => {
position: absolute;
top: 16px;
left: 16px;
+ max-width: calc(100% - 32px);
padding: 12px 16px;
+ box-sizing: border-box;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 1.2em;
diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue
index f4d20c7d8c..30a9b26bef 100644
--- a/packages/frontend/src/components/MkContainer.vue
+++ b/packages/frontend/src/components/MkContainer.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
<slot></slot>
- <button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
+ <button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
</button>
</div>
@@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
thin?: boolean;
naked?: boolean;
foldable?: boolean;
+ onUnfold?: () => boolean; // return false to prevent unfolding
scrollable?: boolean;
expanded?: boolean;
maxHeight?: number | null;
@@ -101,6 +102,13 @@ const omitObserver = new ResizeObserver((entries, observer) => {
calcOmit();
});
+function showMore() {
+ if (props.onUnfold && !props.onUnfold()) return;
+
+ ignoreOmit.value = true;
+ omitted.value = false;
+}
+
onMounted(() => {
watch(showBody, v => {
if (!rootEl.value) return;
diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
index ecbee864dc..e6ab17417d 100644
--- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
+++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
-import { defineProps, shallowRef } from 'vue';
+import { shallowRef } from 'vue';
import MkLink from '@/components/MkLink.vue';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue
index 1079e52030..5ba5de0c4a 100644
--- a/packages/frontend/src/components/MkDriveFileThumbnail.vue
+++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue
@@ -5,13 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
- ref="thumbnail"
- :class="[
- $style.root,
- { [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive },
- ]"
+ v-panel
+ :class="[$style.root, {
+ [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive,
+ [$style.large]: large,
+ }]"
>
- <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
+ <ImgWithBlurhash
+ v-if="isThumbnailAvailable"
+ :hash="file.blurhash"
+ :src="file.thumbnailUrl"
+ :alt="file.name"
+ :title="file.name"
+ :cover="fit !== 'contain'"
+ :forceBlurhash="forceBlurhash"
+ />
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
@@ -34,6 +42,8 @@ const props = defineProps<{
file: Misskey.entities.DriveFile;
fit: 'cover' | 'contain';
highlightWhenSensitive?: boolean;
+ forceBlurhash?: boolean;
+ large?: boolean;
}>();
const is = computed(() => {
@@ -60,7 +70,7 @@ const is = computed(() => {
const isThumbnailAvailable = computed(() => {
return props.file.thumbnailUrl
- ? (is.value === 'image' as const || is.value === 'video')
+ ? (is.value === 'image' || is.value === 'video')
: false;
});
</script>
@@ -101,4 +111,8 @@ const isThumbnailAvailable = computed(() => {
font-size: 32px;
color: #777;
}
+
+.large .icon {
+ font-size: 40px;
+}
</style>
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 0b4114d252..084c81bb52 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<KeepAlive>
<div v-show="opened">
- <MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
+ <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
<slot></slot>
</MkSpacer>
<div v-else>
@@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{
defaultOpen?: boolean;
maxHeight?: number | null;
withSpacer?: boolean;
+ spacerMin?: number;
+ spacerMax?: number;
}>(), {
defaultOpen: false,
maxHeight: null,
withSpacer: true,
+ spacerMin: 14,
+ spacerMax: 22,
});
const rootEl = shallowRef<HTMLElement>();
diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue
index f409f6ce50..96214a9542 100644
--- a/packages/frontend/src/components/MkFormFooter.vue
+++ b/packages/frontend/src/components/MkFormFooter.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
<div style="margin-left: auto;" class="_buttons">
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
- <MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton primary rounded :disabled="!canSaving" @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>
@@ -18,7 +18,7 @@ import { } from 'vue';
import MkButton from './MkButton.vue';
import { i18n } from '@/i18n.js';
-const props = defineProps<{
+const props = withDefaults(defineProps<{
form: {
modifiedCount: {
value: number;
@@ -26,7 +26,10 @@ const props = defineProps<{
discard: () => void;
save: () => void;
};
-}>();
+ canSaving?: boolean;
+}>(), {
+ canSaving: true,
+});
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue
index 8ccbf61e48..d8066857fe 100644
--- a/packages/frontend/src/components/MkInstanceStats.vue
+++ b/packages/frontend/src/components/MkInstanceStats.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.chart">
<div class="selects">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
- <optgroup :label="i18n.ts.federation">
+ <optgroup v-if="shouldShowFederation" :label="i18n.ts.federation">
<option value="federation">{{ i18n.ts._charts.federation }}</option>
<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
</optgroup>
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<optgroup :label="i18n.ts.notes">
<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option>
<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option>
- <option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
+ <option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
</optgroup>
<optgroup :label="i18n.ts.drive">
@@ -46,9 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;">
<option value="active-users">Active users</option>
<option value="notes">Notes</option>
- <option value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
- <option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
- <option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
+ <option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
+ <option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
+ <option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
</MkSelect>
<div class="_panel" :class="$style.heatmap">
<MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/>
@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFoldableSection>
- <MkFoldableSection class="item">
+ <MkFoldableSection v-if="shouldShowFederation" class="item">
<template #header>Federation</template>
<div :class="$style.federation">
<div class="pies">
@@ -84,13 +84,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { onMounted, ref, shallowRef } from 'vue';
+import { onMounted, ref, computed, shallowRef } from 'vue';
import { Chart } from 'chart.js';
import MkSelect from '@/components/MkSelect.vue';
import MkChart from '@/components/MkChart.vue';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
+import { $i } from '@/account.js';
import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
+import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
@@ -100,6 +102,8 @@ import { initChart } from '@/scripts/init-chart.js';
initChart();
+const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator);
+
const chartLimit = 500;
const chartSpan = ref<'hour' | 'day'>('hour');
const chartSrc = ref('active-users');
diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue
index 2a8d5c9f71..9d9cc76822 100644
--- a/packages/frontend/src/components/MkInstanceTicker.vue
+++ b/packages/frontend/src/components/MkInstanceTicker.vue
@@ -4,19 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="$style.root" :style="bg">
+<div :class="$style.root" :style="themeColorStyle">
<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
- <div :class="$style.name">{{ instance.name }}</div>
+ <div :class="$style.name">{{ instanceName }}</div>
</div>
</template>
<script lang="ts" setup>
-import { computed } from 'vue';
-import { instanceName } from '@@/js/config.js';
-import { instance as Instance } from '@/instance.js';
+import { computed, type CSSProperties } from 'vue';
+import { instanceName as localInstanceName } from '@@/js/config.js';
+import { instance as localInstance } from '@/instance.js';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
const props = defineProps<{
+ host: string | null;
instance?: {
faviconUrl?: string | null
name?: string | null
@@ -25,18 +26,28 @@ const props = defineProps<{
}>();
// if no instance data is given, this is for the local instance
-const instance = props.instance ?? {
- name: instanceName,
- themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
-};
+const instanceName = computed(() => props.host == null ? localInstanceName : props.instance?.name ?? props.host);
-const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? '/favicon.ico');
-
-const themeColor = instance.themeColor ?? '#777777';
+const faviconUrl = computed(() => {
+ let imageSrc: string | null = null;
+ if (props.host == null) {
+ if (localInstance.iconUrl == null) {
+ return '/favicon.ico';
+ } else {
+ imageSrc = localInstance.iconUrl;
+ }
+ } else {
+ imageSrc = props.instance?.faviconUrl ?? null;
+ }
+ return getProxiedImageUrlNullable(imageSrc);
+});
-const bg = {
- background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
-};
+const themeColorStyle = computed<CSSProperties>(() => {
+ const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
+ return {
+ background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
+ };
+});
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue
index f64ca4bc77..ac50d82a63 100644
--- a/packages/frontend/src/components/MkMention.vue
+++ b/packages/frontend/src/components/MkMention.vue
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { toUnicode } from 'punycode';
+import { toUnicode } from 'punycode.js';
import { computed } from 'vue';
import { host as localHost } from '@@/js/config.js';
import { $i } from '@/account.js';
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index c766a33823..a446dad0ab 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -288,20 +288,23 @@ const align = () => {
const onOpened = () => {
emit('opened');
- // NOTE: Chromatic テストの際に undefined になる場合がある
- if (content.value == null) return;
+ // contentの子要素にアクセスするためレンダリングの完了を待つ必要がある(nextTickが必要)
+ nextTick(() => {
+ // NOTE: Chromatic テストの際に undefined になる場合がある
+ if (content.value == null) return;
- // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
- const el = content.value.children[0];
- el.addEventListener('mousedown', ev => {
- contentClicking = true;
- window.addEventListener('mouseup', ev => {
- // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
- window.setTimeout(() => {
- contentClicking = false;
- }, 100);
- }, { passive: true, once: true });
- }, { passive: true });
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const el = content.value.children[0];
+ el.addEventListener('mousedown', ev => {
+ contentClicking = true;
+ window.addEventListener('mouseup', ev => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ window.setTimeout(() => {
+ contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+ });
};
const onClosed = () => {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 69c6b4f357..9271e9e4b7 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/>
<div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
<MkNoteHeader :note="appearNote" :mini="true" @click.stop/>
- <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
+ <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<bdi>
<p v-if="appearNote.cw != null" :class="$style.cw">
@@ -100,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/>
</div>
- <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/>
+ <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
</div>
@@ -179,13 +179,23 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
</template>
</I18n>
- <I18n v-else :src="i18n.ts.userSaysSomething" tag="small">
+ <I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
+ <I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
+ <template #name>
+ <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ <template #word>
+ {{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
+ </template>
+ </I18n>
</div>
<div v-else>
<!--
@@ -319,6 +329,7 @@ const isDeleted = ref(false);
const renoted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
+const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
@@ -343,13 +354,18 @@ const renoteTooltip = computeRenoteTooltip(renoted);
/* Overload FunctionにLintが対応していないのでコメントアウト
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
-function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
+function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
-function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' {
+function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
if (mutedWords != null) {
- if (checkWordMute(noteToCheck, $i, mutedWords)) return true;
- if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true;
- if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true;
+ const result = checkWordMute(noteToCheck, $i, mutedWords);
+ if (Array.isArray(result)) return result;
+
+ const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
+ if (Array.isArray(replyResult)) return replyResult;
+
+ const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
+ if (Array.isArray(renoteResult)) return renoteResult;
}
if (checkOnly) return false;
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 2eb4431de2..6c52714f46 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
- <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
+ <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
</header>
<div :class="$style.noteContent">
@@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
- <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/>
+ <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
</div>
diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue
new file mode 100644
index 0000000000..bf105c3c27
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteMediaGrid.vue
@@ -0,0 +1,109 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+ <template v-for="file in note.files">
+ <div
+ v-if="(((
+ (defaultStore.state.nsfw === 'force' || file.isSensitive) &&
+ defaultStore.state.nsfw !== 'ignore'
+ ) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) &&
+ !showingFiles.has(file.id)
+ )"
+ :class="[$style.filePreview, { [$style.square]: square }]"
+ @click="showingFiles.add(file.id)"
+ >
+ <MkDriveFileThumbnail
+ :file="file"
+ fit="cover"
+ :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
+ :forceBlurhash="true"
+ :large="true"
+ :class="$style.file"
+ />
+ <div :class="$style.sensitive">
+ <div>
+ <div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div>
+ <div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div>
+ <div>{{ i18n.ts.clickToShow }}</div>
+ </div>
+ </div>
+ </div>
+ <MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)">
+ <MkDriveFileThumbnail
+ :file="file"
+ fit="cover"
+ :highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
+ :large="true"
+ :class="$style.file"
+ />
+ </MkA>
+ </template>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { notePage } from '@/filters/note.js';
+import { i18n } from '@/i18n.js';
+import * as Misskey from 'misskey-js';
+import { defaultStore } from '@/store.js';
+import bytes from '@/filters/bytes.js';
+
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+
+defineProps<{
+ note: Misskey.entities.Note;
+ square?: boolean;
+}>();
+
+const showingFiles = ref<Set<string>>(new Set());
+</script>
+
+<style lang="scss" module>
+.square {
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1;
+}
+
+.filePreview {
+ position: relative;
+ height: 128px;
+ border-radius: calc(var(--MI-radius) / 2);
+ overflow: clip;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.square {
+ height: 100%;
+ }
+}
+
+.file {
+ width: 100%;
+ height: 100%;
+ border-radius: calc(var(--MI-radius) / 2);
+}
+
+.sensitive {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ font-size: 0.8em;
+ text-align: center;
+ padding: 8px;
+ box-sizing: border-box;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(5px);
+ cursor: pointer;
+}
+</style>
diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue
index b13df2813b..bd157d0b14 100644
--- a/packages/frontend/src/components/MkNotes.vue
+++ b/packages/frontend/src/components/MkNotes.vue
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, shallowRef, ref } from 'vue';
+import { defineAsyncComponent, shallowRef } from 'vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 84189211b6..3ff4cc215c 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
import { url } from '@@/js/config.js';
import { getScrollContainer } from '@@/js/scroll.js';
+import MkUserName from './global/MkUserName.vue';
import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js';
@@ -43,7 +44,6 @@ import { openingWindowsCount } from '@/os.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { useRouterFactory } from '@/router/supplier.js';
import { mainRouter } from '@/router/main.js';
-import MkUserName from './global/MkUserName.vue';
const props = defineProps<{
initialPath: string;
diff --git a/packages/frontend/src/components/MkPagingButtons.vue b/packages/frontend/src/components/MkPagingButtons.vue
new file mode 100644
index 0000000000..fe59efd83a
--- /dev/null
+++ b/packages/frontend/src/components/MkPagingButtons.vue
@@ -0,0 +1,124 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+ <MkButton primary :disabled="min === current" @click="onToPrevButtonClicked">&lt;</MkButton>
+
+ <div :class="$style.buttons">
+ <div v-if="prevDotVisible" :class="$style.headTailButtons">
+ <MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton>
+ <span class="ti ti-dots"/>
+ </div>
+
+ <MkButton
+ v-for="i in buttonRanges" :key="i"
+ :disabled="current === i"
+ @click="onNumberButtonClicked(i)"
+ >
+ {{ i }}
+ </MkButton>
+
+ <div v-if="nextDotVisible" :class="$style.headTailButtons">
+ <span class="ti ti-dots"/>
+ <MkButton @click="onToTailButtonClicked">{{ max }}</MkButton>
+ </div>
+ </div>
+
+ <MkButton primary :disabled="max === current" @click="onToNextButtonClicked">&gt;</MkButton>
+</div>
+</template>
+
+<script setup lang="ts">
+
+import { computed, toRefs } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+
+const min = 1;
+
+const emit = defineEmits<{
+ (ev: 'pageChanged', pageNumber: number): void;
+}>();
+
+const props = defineProps<{
+ current: number;
+ max: number;
+ buttonCount: number;
+}>();
+
+const { current, max } = toRefs(props);
+
+const buttonCount = computed(() => Math.min(max.value, props.buttonCount));
+const buttonCountHalf = computed(() => Math.floor(buttonCount.value / 2));
+const buttonCountStart = computed(() => Math.min(Math.max(min, current.value - buttonCountHalf.value), max.value - buttonCount.value + 1));
+const buttonRanges = computed(() => Array.from({ length: buttonCount.value }, (_, i) => buttonCountStart.value + i));
+
+const prevDotVisible = computed(() => (current.value - 1 > buttonCountHalf.value) && (max.value > buttonCount.value));
+const nextDotVisible = computed(() => (current.value < max.value - buttonCountHalf.value) && (max.value > buttonCount.value));
+
+if (_DEV_) {
+ console.log('[MkPagingButtons]', current.value, max.value, buttonCount.value, buttonCountHalf.value);
+ console.log('[MkPagingButtons]', current.value < max.value - buttonCountHalf.value);
+ console.log('[MkPagingButtons]', max.value > buttonCount.value);
+}
+
+function onNumberButtonClicked(pageNumber: number) {
+ emit('pageChanged', pageNumber);
+}
+
+function onToHeadButtonClicked() {
+ emit('pageChanged', min);
+}
+
+function onToPrevButtonClicked() {
+ const newPageNumber = current.value <= min ? min : current.value - 1;
+ emit('pageChanged', newPageNumber);
+}
+
+function onToNextButtonClicked() {
+ const newPageNumber = current.value >= max.value ? max.value : current.value + 1;
+ emit('pageChanged', newPageNumber);
+}
+
+function onToTailButtonClicked() {
+ emit('pageChanged', max.value);
+}
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 24px;
+
+ button {
+ border-radius: 9999px;
+ min-width: 2.5em;
+ min-height: 2.5em;
+ max-width: 2.5em;
+ max-height: 2.5em;
+ padding: 4px;
+ }
+}
+
+.buttons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.headTailButtons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ span {
+ font-size: 0.75em;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue
index a414676bda..f6218de4c8 100644
--- a/packages/frontend/src/components/MkPoll.vue
+++ b/packages/frontend/src/components/MkPoll.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template>
- <Mfm :text="choice.text" :plain="true"/>
+ <Mfm :text="choice.text" :plain="true" :author="author" :emojiUrls="emojiUrls"/>
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
</span>
</li>
@@ -48,6 +48,8 @@ const props = defineProps<{
poll: NonNullable<Misskey.entities.Note['poll']>;
readOnly?: boolean;
local?: boolean;
+ emojiUrls?: Record<string, string>;
+ author?: Misskey.entities.UserLite;
}>();
const remaining = ref(-1);
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 6f057ed5eb..059de8011c 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -46,14 +46,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template>
<template v-else>{{ submitText }}</template>
- <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i>
+ <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
</div>
</button>
</div>
</header>
<MkNoteSimple v-if="reply" :class="$style.targetNote" :hideFiles="true" :note="reply"/>
- <MkNoteSimple v-if="renote" :class="$style.targetNote" :hideFiles="true" :note="renote"/>
- <div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
+ <MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :hideFiles="true" :note="renoteTargetNote"/>
+ <div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null; renoteTargetNote = null;"><i class="ti ti-x"></i></button></div>
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
<div :class="$style.visibleUsers">
@@ -106,13 +106,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw } from 'vue';
+import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw, type ShallowRef } from 'vue';
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
-import { toASCII } from 'punycode/';
+import { toASCII } from 'punycode.js';
import { host, url } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
+import type { PostFormProps } from '@/types/post-form.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
@@ -136,7 +137,6 @@ import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
-import type { PostFormProps } from '@/types/post-form.js';
import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
const $i = signinRequired();
@@ -202,12 +202,13 @@ const justEndedComposition = ref(false);
const scheduleNote = ref<{
scheduledAt: number | null;
} | null>(null);
+const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
const draftKey = computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : '';
- if (props.renote) {
- key += `renote:${props.renote.id}`;
+ if (renoteTargetNote.value) {
+ key += `renote:${renoteTargetNote.value.id}`;
} else if (props.reply) {
key += `reply:${props.reply.id}`;
} else {
@@ -218,7 +219,7 @@ const draftKey = computed((): string => {
});
const placeholder = computed((): string => {
- if (props.renote) {
+ if (renoteTargetNote.value) {
return i18n.ts._postForm.quotePlaceholder;
} else if (props.reply) {
return i18n.ts._postForm.replyPlaceholder;
@@ -238,7 +239,7 @@ const placeholder = computed((): string => {
});
const submitText = computed((): string => {
- return props.renote
+ return renoteTargetNote.value
? i18n.ts.quote
: props.reply
? i18n.ts.reply
@@ -262,11 +263,12 @@ const canPost = computed((): boolean => {
1 <= textLength.value ||
1 <= files.value.length ||
poll.value != null ||
- props.renote != null ||
+ renoteTargetNote.value != null ||
quoteId.value != null
) &&
(textLength.value <= maxTextLength.value) &&
(cwLength.value <= maxCwLength.value) &&
+ (files.value.length <= 16) &&
(!poll.value || poll.value.choices.length >= 2);
});
@@ -647,7 +649,7 @@ async function onPaste(ev: ClipboardEvent) {
const paste = ev.clipboardData.getData('text');
- if (!props.renote && !quoteId.value && paste.startsWith(url + '/notes/')) {
+ if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
ev.preventDefault();
os.confirm({
@@ -863,7 +865,7 @@ async function post(ev?: MouseEvent) {
text: text.value === '' ? null : text.value,
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
replyId: props.reply ? props.reply.id : undefined,
- renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined,
+ renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
channelId: props.channel ? props.channel.id : undefined,
poll: poll.value,
cw: useCw.value ? cw.value ?? '' : null,
@@ -953,7 +955,7 @@ async function post(ev?: MouseEvent) {
claimAchievement('brainDiver');
}
- if (props.renote && (props.renote.userId === $i.id) && text.length > 0) {
+ if (renoteTargetNote.value && (renoteTargetNote.value.userId === $i.id) && text.length > 0) {
claimAchievement('selfQuote');
}
@@ -1163,7 +1165,7 @@ onMounted(() => {
users.forEach(u => pushVisibleUser(u));
});
}
- quoteId.value = init.renote ? init.renote.id : null;
+ quoteId.value = renoteTargetNote.value ? renoteTargetNote.value.id : null;
reactionAcceptance.value = init.reactionAcceptance;
if (init.isSchedule) {
scheduleNote.value = {
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index 11444d8d78..bab7d22112 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
</Sortable>
- <p :class="$style.remain">{{ 16 - props.modelValue.length }}/16</p>
+ <p :class="[$style.remain, {
+ [$style.exceeded]: props.modelValue.length > 16,
+ }]">{{ 16 - props.modelValue.length }}/16</p>
</div>
</template>
@@ -239,5 +241,9 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
margin: 0;
padding: 0;
font-size: 90%;
+
+ &.exceeded {
+ color: var(--MI_THEME-error);
+ }
}
</style>
diff --git a/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
new file mode 100644
index 0000000000..873b276b3d
--- /dev/null
+++ b/packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
@@ -0,0 +1,132 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkWindow
+ ref="windowEl"
+ :initialWidth="400"
+ :initialHeight="500"
+ :canResize="true"
+ @close="windowEl?.close()"
+ @closed="emit('closed')"
+>
+ <template #header>:{{ name }}:</template>
+
+ <div style="display: flex; flex-direction: column; min-height: 100%;">
+ <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
+ <div class="_gaps_m">
+ <div v-if="imgUrl != null" :class="$style.imgs">
+ <div style="background: #000;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img" :alt="name"/>
+ </div>
+ <div style="background: #222;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img" :alt="name"/>
+ </div>
+ <div style="background: #ddd;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img" :alt="name"/>
+ </div>
+ <div style="background: #fff;" :class="$style.imgContainer">
+ <img :src="imgUrl" :class="$style.img" :alt="name"/>
+ </div>
+ </div>
+
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.id }}</template>
+ <template #value>{{ name }}</template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.host }}</template>
+ <template #value>{{ host }}</template>
+ </MkKeyValue>
+ <MkKeyValue>
+ <template #key>{{ i18n.ts.license }}</template>
+ <template #value>{{ license }}</template>
+ </MkKeyValue>
+ </div>
+ </MkSpacer>
+ <div :class="$style.footer">
+ <MkButton primary rounded style="margin: 0 auto;" @click="done">
+ <i class="ti ti-plus"></i> {{ i18n.ts.import }}
+ </MkButton>
+ </div>
+ </div>
+</MkWindow>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkWindow from '@/components/MkWindow.vue';
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+
+const props = defineProps<{
+ emoji: {
+ id: string,
+ name: string,
+ host: string,
+ license: string | null,
+ url: string
+ },
+}>();
+
+const emit = defineEmits<{
+ // 必要なら戻り値を増やす
+ (ev: 'done'): void,
+ (ev: 'closed'): void
+}>();
+
+const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
+
+const name = computed(() => props.emoji.name);
+const host = computed(() => props.emoji.host);
+const license = computed(() => props.emoji.license);
+const imgUrl = computed(() => props.emoji.url);
+
+async function done() {
+ await os.apiWithDialog('admin/emoji/copy', {
+ emojiId: props.emoji.id,
+ });
+
+ emit('done');
+ windowEl.value?.close();
+}
+</script>
+
+<style lang="scss" module>
+.imgs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.imgContainer {
+ padding: 8px;
+ border-radius: 6px;
+}
+
+.img {
+ display: block;
+ height: 64px;
+ width: 64px;
+ object-fit: contain;
+}
+
+.footer {
+ position: sticky;
+ z-index: 10000;
+ bottom: 0;
+ left: 0;
+ padding: 12px;
+ border-top: solid 0.5px var(--MI_THEME-divider);
+ background: var(--MI_THEME-acrylicBg);
+ -webkit-backdrop-filter: var(--MI-blur, blur(15px));
+ backdrop-filter: var(--MI-blur, blur(15px));
+}
+</style>
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts
new file mode 100644
index 0000000000..411d62edf9
--- /dev/null
+++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { http, HttpResponse } from 'msw';
+import { role } from '../../.storybook/fakes.js';
+import { commonHandlers } from '../../.storybook/mocks.js';
+import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue';
+
+const roles = [
+ role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'),
+ role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'),
+ role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'),
+ role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'),
+];
+
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkRoleSelectDialog,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkRoleSelectDialog v-bind="props" />',
+ };
+ },
+ args: {
+ initialRoleIds: undefined,
+ infoMessage: undefined,
+ title: undefined,
+ publicOnly: true,
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/admin/roles/list', ({ params }) => {
+ return HttpResponse.json(roles);
+ }),
+ ],
+ },
+ },
+ decorators: [() => ({
+ template: '<div style="width:100cqmin"><story/></div>',
+ })],
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const InitialIds = {
+ ...Default,
+ args: {
+ ...Default.args,
+ initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id],
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const InfoMessage = {
+ ...Default,
+ args: {
+ ...Default.args,
+ infoMessage: 'This is a message.',
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const Title = {
+ ...Default,
+ args: {
+ ...Default.args,
+ title: 'Select roles',
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const Full = {
+ ...Default,
+ args: {
+ ...Default.args,
+ initialRoleIds: roles.map(it => it.id),
+ infoMessage: InfoMessage.args.infoMessage,
+ title: Title.args.title,
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const FullWithPrivate = {
+ ...Default,
+ args: {
+ ...Default.args,
+ initialRoleIds: roles.map(it => it.id),
+ infoMessage: InfoMessage.args.infoMessage,
+ title: Title.args.title,
+ publicOnly: false,
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue
new file mode 100644
index 0000000000..8d11bd855f
--- /dev/null
+++ b/packages/frontend/src/components/MkRoleSelectDialog.vue
@@ -0,0 +1,200 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="windowEl"
+ :withOkButton="false"
+ :okButtonDisabled="false"
+ :width="400"
+ :height="500"
+ @close="onCloseModalWindow"
+ @closed="$emit('dispose')"
+>
+ <template #header>{{ title }}</template>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <MkLoading v-if="fetching"/>
+ <div v-else class="_gaps" :class="$style.root">
+ <div :class="$style.header">
+ <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ </div>
+
+ <div v-if="selectedRoles.length > 0" class="_gaps" :class="$style.roleItemArea">
+ <div v-for="role in selectedRoles" :key="role.id" :class="$style.roleItem">
+ <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
+ <button class="_button" :class="$style.roleUnAssign" @click="removeRole(role.id)"><i class="ti ti-x"></i></button>
+ </div>
+ </div>
+ <div v-else :class="$style.roleItemArea" style="text-align: center">
+ {{ i18n.ts._roleSelectDialog.notSelected }}
+ </div>
+
+ <MkInfo v-if="infoMessage">{{ infoMessage }}</MkInfo>
+
+ <div :class="$style.buttons">
+ <MkButton primary @click="onOkClicked">{{ i18n.ts.ok }}</MkButton>
+ <MkButton @click="onCancelClicked">{{ i18n.ts.cancel }}</MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, toRefs } from 'vue';
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkRolePreview from '@/components/MkRolePreview.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import * as os from '@/os.js';
+import MkSpacer from '@/components/global/MkSpacer.vue';
+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: 'dispose'),
+}>();
+
+const props = withDefaults(defineProps<{
+ initialRoleIds?: string[],
+ infoMessage?: string,
+ title?: string,
+ publicOnly: boolean,
+}>(), {
+ initialRoleIds: undefined,
+ infoMessage: undefined,
+ title: undefined,
+ publicOnly: true,
+});
+
+const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props);
+
+const windowEl = ref<InstanceType<typeof MkModalWindow>>();
+const roles = ref<Misskey.entities.Role[]>([]);
+const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []);
+const fetching = ref(false);
+
+const selectedRoles = computed(() => {
+ const r = roles.value.filter(role => selectedRoleIds.value.includes(role.id));
+ r.sort((a, b) => {
+ if (a.displayOrder !== b.displayOrder) {
+ return b.displayOrder - a.displayOrder;
+ }
+
+ return a.id.localeCompare(b.id);
+ });
+ return r;
+});
+
+async function fetchRoles() {
+ fetching.value = true;
+ const result = await misskeyApi('admin/roles/list', {});
+ roles.value = result.filter(it => publicOnly.value ? it.isPublic : true);
+ fetching.value = false;
+}
+
+async function addRole() {
+ const items = roles.value
+ .filter(r => r.isPublic)
+ .filter(r => !selectedRoleIds.value.includes(r.id))
+ .map(r => ({ text: r.name, value: r }));
+
+ const { canceled, result: role } = await os.select({ items });
+ if (canceled) {
+ return;
+ }
+
+ selectedRoleIds.value.push(role.id);
+}
+
+async function removeRole(roleId: string) {
+ selectedRoleIds.value = selectedRoleIds.value.filter(x => x !== roleId);
+}
+
+function onOkClicked() {
+ emit('done', selectedRoles.value);
+ windowEl.value?.close();
+}
+
+function onCancelClicked() {
+ emit('close');
+ windowEl.value?.close();
+}
+
+function onCloseModalWindow() {
+ emit('close');
+ windowEl.value?.close();
+}
+
+fetchRoles();
+</script>
+
+<style module lang="scss">
+.root {
+ max-height: 410px;
+ height: 410px;
+ display: flex;
+ flex-direction: column;
+}
+
+.roleItemArea {
+ background-color: var(--MI_THEME-acrylicBg);
+ border-radius: var(--MI-radius);
+ padding: 12px;
+ overflow-y: auto;
+}
+
+.roleItem {
+ display: flex;
+}
+
+.role {
+ flex: 1;
+}
+
+.roleUnAssign {
+ width: 32px;
+ height: 32px;
+ margin-left: 8px;
+ align-self: center;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.title {
+ flex: 1;
+}
+
+.addRoleButton {
+ min-width: 32px;
+ min-height: 32px;
+ max-width: 32px;
+ max-height: 32px;
+ margin-left: 8px;
+ align-self: center;
+ padding: 0;
+}
+
+.buttons {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ margin-top: auto;
+}
+
+.divider {
+ border-top: solid 0.5px var(--MI_THEME-divider);
+}
+
+</style>
diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue
index 34c22abc31..e98ac9cfd2 100644
--- a/packages/frontend/src/components/MkSignin.input.vue
+++ b/packages/frontend/src/components/MkSignin.input.vue
@@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref } from 'vue';
-import { toUnicode } from 'punycode/';
+import { toUnicode } from 'punycode.js';
import { query, extractDomain } from '@@/js/url.js';
import { host as configHost } from '@@/js/config.js';
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 4a6219071b..d6177762d2 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -141,6 +141,7 @@ function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
return;
}
emit('login', res.signinResponse);
+ onLoginSucceeded(res.signinResponse);
}).catch(onSigninApiError);
} else if (userInfo.value != null) {
tryLogin({
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index e636712389..3560bebace 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -85,7 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
-import { toUnicode } from 'punycode/';
+import { toUnicode } from 'punycode.js';
import * as Misskey from 'misskey-js';
import * as config from '@@/js/config.js';
import MkButton from './MkButton.vue';
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue
index 06481b808c..d1685c6990 100644
--- a/packages/frontend/src/components/MkSignupDialog.rules.vue
+++ b/packages/frontend/src/components/MkSignupDialog.rules.vue
@@ -10,8 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
- <div v-if="instance.disableRegistration">
- <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ <div v-if="instance.disableRegistration || instance.federation !== 'all'" class="_gaps_s">
+ <MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ <MkInfo v-if="instance.federation === 'specified'" warn>{{ i18n.ts.federationSpecified }}</MkInfo>
+ <MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo>
</div>
<div style="text-align: center;">
diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts
new file mode 100644
index 0000000000..f023b5d72b
--- /dev/null
+++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts
@@ -0,0 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type SortOrderDirection = '+' | '-'
+
+export type SortOrder<T extends string> = {
+ key: T;
+ direction: SortOrderDirection;
+}
diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue
new file mode 100644
index 0000000000..9decacc5f5
--- /dev/null
+++ b/packages/frontend/src/components/MkSortOrderEditor.vue
@@ -0,0 +1,118 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.sortOrderArea">
+ <div :class="$style.sortOrderAreaTags">
+ <MkTagItem
+ v-for="order in currentOrders"
+ :key="order.key"
+ :iconClass="order.direction === '+' ? 'ti ti-arrow-up' : 'ti ti-arrow-down'"
+ :exButtonIconClass="'ti ti-x'"
+ :content="order.key"
+ :class="$style.sortOrderTag"
+ @click="onToggleSortOrderButtonClicked(order)"
+ @exButtonClick="onRemoveSortOrderButtonClicked(order)"
+ />
+ </div>
+ <MkButton :class="$style.sortOrderAddButton" @click="onAddSortOrderButtonClicked">
+ <span class="ti ti-plus"></span>
+ </MkButton>
+</div>
+</template>
+
+<script setup lang="ts" generic="T extends string">
+import { toRefs } from 'vue';
+import MkTagItem from '@/components/MkTagItem.vue';
+import MkButton from '@/components/MkButton.vue';
+import { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
+import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
+
+const emit = defineEmits<{
+ (ev: 'update', sortOrders: SortOrder<T>[]): void;
+}>();
+
+const props = defineProps<{
+ baseOrderKeyNames: T[];
+ currentOrders: SortOrder<T>[];
+}>();
+
+const { currentOrders } = toRefs(props);
+
+function onToggleSortOrderButtonClicked(order: SortOrder<T>) {
+ switch (order.direction) {
+ case '+':
+ order.direction = '-';
+ break;
+ case '-':
+ order.direction = '+';
+ break;
+ }
+
+ emitOrder(currentOrders.value);
+}
+
+function onAddSortOrderButtonClicked(ev: MouseEvent) {
+ const menuItems: MenuItem[] = props.baseOrderKeyNames
+ .filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey))
+ .map(it => {
+ return {
+ text: it,
+ action: () => {
+ emitOrder([...currentOrders.value, { key: it, direction: '+' }]);
+ },
+ };
+ });
+ os.contextMenu(menuItems, ev);
+}
+
+function onRemoveSortOrderButtonClicked(order: SortOrder<T>) {
+ emitOrder(currentOrders.value.filter(it => it.key !== order.key));
+}
+
+function emitOrder(sortOrders: SortOrder<T>[]) {
+ emit('update', sortOrders);
+}
+
+</script>
+
+<style module lang="scss">
+.sortOrderArea {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start;
+}
+
+.sortOrderAreaTags {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.sortOrderAddButton {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-sizing: border-box;
+ min-width: 2.0em;
+ min-height: 2.0em;
+ max-width: 2.0em;
+ max-height: 2.0em;
+ padding: 8px;
+ margin-left: auto;
+ border-radius: 9999px;
+ background-color: var(--MI_THEME-buttonBg);
+}
+
+.sortOrderTag {
+ user-select: none;
+ cursor: pointer;
+}
+</style>
diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue
index 8491ce2f84..b3fc67c0df 100644
--- a/packages/frontend/src/components/MkSparkle.vue
+++ b/packages/frontend/src/components/MkSparkle.vue
@@ -39,32 +39,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<!-- MFMで上位レイヤーに表示されるため、リンクをクリックできるようにstyleにpointer-events: none;を付与。 -->
<svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg" style="position: absolute; top: -32px; left: -32px; pointer-events: none;">
+ <!-- SVGのanimateTransformを使用するとChromeで描画できなくなるためCSSアニメーションを使用している (Issue 14155) -->
<path
- style="transform-origin: center; transform-box: fill-box;"
- :transform="`translate(${particle.x} ${particle.y})`"
+ :style="{
+ '--translateX': particle.x + 'px',
+ '--translateY': particle.y + 'px',
+ '--duration': particle.dur + 'ms',
+ '--size': particle.size,
+ }"
+ :class="$style.particle"
:fill="particle.color"
d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z"
- >
- <animateTransform
- attributeName="transform"
- attributeType="XML"
- type="rotate"
- from="0 0 0"
- to="360 0 0"
- :dur="`${particle.dur}ms`"
- repeatCount="1"
- additive="sum"
- />
- <animateTransform
- attributeName="transform"
- attributeType="XML"
- type="scale"
- :values="`0; ${particle.size}; 0`"
- :dur="`${particle.dur}ms`"
- repeatCount="1"
- additive="sum"
- />
- </path>
+ ></path>
</svg>
</span>
</template>
@@ -130,4 +116,25 @@ onUnmounted(() => {
position: relative;
display: inline-block;
}
+
+.particle {
+ transform-origin: center;
+ transform-box: fill-box;
+ translate: var(--translateX) var(--translateY);
+ animation: particleAnimation var(--duration) linear infinite;
+}
+
+@keyframes particleAnimation {
+ 0% {
+ rotate: 0deg;
+ scale: 0;
+ }
+ 50% {
+ scale: var(--size);
+ }
+ 100% {
+ rotate: 360deg;
+ scale: 0;
+ }
+}
</style>
diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
index a32fd53c51..145de3b9d3 100644
--- a/packages/frontend/src/components/MkSubNoteContent.vue
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</details>
<details v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary>
- <MkPoll :noteId="note.id" :poll="note.poll"/>
+ <MkPoll :noteId="note.id" :poll="note.poll" :author="note.user" :emojiUrls="note.emojis"/>
</details>
<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click.stop="collapsed = false">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
@@ -42,11 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import * as mfm from '@transfem-org/sfm-js';
+import { shouldCollapsed } from '@@/js/collapsed.js';
import MkMediaList from '@/components/MkMediaList.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
-import { shouldCollapsed } from '@@/js/collapsed.js';
import { defaultStore } from '@/store.js';
import { useRouter } from '@/router/supplier.js';
import * as os from '@/os.js';
diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue
index c9c173aa35..56e8fcfa37 100644
--- a/packages/frontend/src/components/MkSuperMenu.vue
+++ b/packages/frontend/src/components/MkSuperMenu.vue
@@ -47,7 +47,7 @@ export type SuperMenuDef = {
active?: boolean;
action: (ev: MouseEvent) => void;
} | {
- type: 'link';
+ type?: 'link';
to: string;
icon?: string;
text: string;
diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts
new file mode 100644
index 0000000000..3f243ff651
--- /dev/null
+++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+/* eslint-disable import/no-default-export */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import MkTagItem from './MkTagItem.vue';
+
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkTagItem: MkTagItem,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ events() {
+ return {
+ click: action('click'),
+ exButtonClick: action('exButtonClick'),
+ };
+ },
+ },
+ template: '<MkTagItem v-bind="props" v-on="events"></MkTagItem>',
+ };
+ },
+ args: {
+ content: 'name',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
+
+export const Icon = {
+ ...Default,
+ args: {
+ ...Default.args,
+ iconClass: 'ti ti-arrow-up',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
+
+export const ExButton = {
+ ...Default,
+ args: {
+ ...Default.args,
+ exButtonIconClass: 'ti ti-x',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
+
+export const IconExButton = {
+ ...Default,
+ args: {
+ ...Default.args,
+ iconClass: 'ti ti-arrow-up',
+ exButtonIconClass: 'ti ti-x',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue
new file mode 100644
index 0000000000..8b7460f3a3
--- /dev/null
+++ b/packages/frontend/src/components/MkTagItem.vue
@@ -0,0 +1,76 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+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>
+ <MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
+ <span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
+ </MkButton>
+</div>
+</template>
+
+<script setup lang="ts">
+import MkButton from '@/components/MkButton.vue';
+
+const emit = defineEmits<{
+ (ev: 'click', payload: MouseEvent): void;
+ (ev: 'exButtonClick', payload: MouseEvent): void;
+}>();
+
+defineProps<{
+ iconClass?: string;
+ content: string;
+ exButtonIconClass?: string
+}>();
+</script>
+
+<style module lang="scss">
+$buttonSize : 1.8em;
+
+.root {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ padding: 4px 6px;
+ gap: 3px;
+
+ background-color: var(--MI_THEME-buttonBg);
+
+ &:hover {
+ background-color: var(--MI_THEME-buttonHoverBg);
+ }
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.70em;
+}
+
+.exButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ max-height: $buttonSize;
+ max-width: $buttonSize;
+ min-height: $buttonSize;
+ min-width: $buttonSize;
+ padding: 0;
+ box-sizing: border-box;
+ font-size: 0.65em;
+}
+
+.exButtonIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.80em;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue
index 85d4666172..63af652cbc 100644
--- a/packages/frontend/src/components/MkUserSelectDialog.vue
+++ b/packages/frontend/src/components/MkUserSelectDialog.vue
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>{{ i18n.ts.selectUser }}</template>
<div>
<div :class="$style.form">
- <MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search">
+ <MkInput v-if="computedLocalOnly" v-model="username" :autofocus="true" @update:modelValue="search">
<template #label>{{ i18n.ts.username }}</template>
<template #prefix>@</template>
</MkInput>
@@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { onMounted, ref, shallowRef } from 'vue';
+import { onMounted, ref, computed, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkInput from '@/components/MkInput.vue';
import FormSplit from '@/components/form/split.vue';
@@ -70,6 +70,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
import { host as currentHost, hostname } from '@@/js/config.js';
const emit = defineEmits<{
@@ -86,6 +87,8 @@ const props = withDefaults(defineProps<{
localOnly: false,
});
+const computedLocalOnly = computed(() => props.localOnly || instance.federation === 'none');
+
const username = ref('');
const host = ref('');
const users = ref<Misskey.entities.UserLite[]>([]);
@@ -98,10 +101,9 @@ function search() {
users.value = [];
return;
}
-
misskeyApi('users/search-by-username-and-host', {
username: username.value,
- host: props.localOnly ? '.' : host.value,
+ host: computedLocalOnly.value ? '.' : host.value,
limit: 10,
detail: false,
}).then(_users => {
@@ -143,7 +145,7 @@ onMounted(() => {
}).then(foundUsers => {
let _users = foundUsers;
_users = _users.filter((u) => {
- if (props.localOnly) {
+ if (computedLocalOnly.value) {
return u.host == null;
} else {
return true;
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index 54f2ee655c..6d2a44e985 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -18,8 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="sanitizeHtml(instance.description) || i18n.ts.headlineMisskey"></div>
</div>
- <div v-if="instance.disableRegistration" :class="$style.mainWarn">
- <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ <div v-if="instance.disableRegistration || instance.federation !== 'all'" :class="$style.mainWarn" class="_gaps_s">
+ <MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
+ <MkInfo v-if="instance.federation === 'specified'" warn>{{ i18n.ts.federationSpecified }}</MkInfo>
+ <MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo>
</div>
<div v-if="instance.approvalRequiredForSignup" :class="$style.mainWarn">
<MkInfo warn>{{ i18n.ts.approvalRequiredToRegister }}</MkInfo>
diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue
index b987283a65..3446e3d6e2 100644
--- a/packages/frontend/src/components/MkWidgets.vue
+++ b/packages/frontend/src/components/MkWidgets.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.editHeader">
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
<template #label>{{ i18n.ts.selectWidget }}</template>
- <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
+ <option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
</MkSelect>
<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>
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</Sortable>
</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 => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
</div>
</template>
@@ -50,13 +50,14 @@ export type DefaultStoredWidget = {
</script>
<script lang="ts" setup>
-import { defineAsyncComponent, ref } from 'vue';
+import { defineAsyncComponent, ref, computed } from 'vue';
import { v4 as uuid } from 'uuid';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
-import { widgets as widgetDefs } from '@/widgets/index.js';
+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 { isLink } from '@@/js/is-link.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -66,6 +67,16 @@ const props = defineProps<{
edit: boolean;
}>();
+const _widgetDefs = computed(() => {
+ if (instance.federation === 'none') {
+ return widgetDefs.filter(x => !federationWidgets.includes(x));
+ } else {
+ return widgetDefs;
+ }
+});
+
+const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name)));
+
const emit = defineEmits<{
(ev: 'updateWidgets', widgets: Widget[]): void;
(ev: 'addWidget', widget: Widget): void;
diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue
index 2bfe5cc157..800b3afc65 100644
--- a/packages/frontend/src/components/SkInstanceTicker.vue
+++ b/packages/frontend/src/components/SkInstanceTicker.vue
@@ -4,40 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="$style.root" :style="bg">
+<div :class="$style.root" :style="themeColorStyle">
<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
- <div :class="$style.name">{{ instance.name }}</div>
+ <div :class="$style.name">{{ instanceName }}</div>
</div>
</template>
<script lang="ts" setup>
-import { computed } from 'vue';
-import { instanceName } from '@@/js/config.js';
-import { instance as Instance } from '@/instance.js';
+import { computed, type CSSProperties } from 'vue';
+import { instanceName as localInstanceName } from '@@/js/config.js';
+import { instance as localInstance } from '@/instance.js';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
const props = defineProps<{
+ host: string | null;
instance?: {
- faviconUrl?: string
- name: string
- themeColor?: string
+ faviconUrl?: string | null
+ name?: string | null
+ themeColor?: string | null
}
}>();
// if no instance data is given, this is for the local instance
-const instance = props.instance ?? {
- name: instanceName,
- themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
-};
+const instanceName = computed(() => props.host == null ? localInstanceName : props.instance?.name ?? props.host);
-const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico');
-
-const themeColor = instance.themeColor ?? '#777777';
+const faviconUrl = computed(() => {
+ let imageSrc: string | null = null;
+ if (props.host == null) {
+ if (localInstance.iconUrl == null) {
+ return '/favicon.ico';
+ } else {
+ imageSrc = localInstance.iconUrl;
+ }
+ } else {
+ imageSrc = props.instance?.faviconUrl ?? null;
+ }
+ return getProxiedImageUrlNullable(imageSrc);
+});
-const bg = {
- //background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
- background: `${themeColor}`,
-};
+const themeColorStyle = computed<CSSProperties>(() => {
+ const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
+ return {
+ background: `${themeColor}`,
+ };
+});
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index 7cd5c2e0cf..323ca283bf 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -102,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/>
</div>
- <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/>
+ <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" :class="$style.urlPreview" @click.stop/>
</div>
@@ -180,13 +180,23 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
</template>
</I18n>
- <I18n v-else :src="i18n.ts.userSaysSomething" tag="small">
+ <I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</I18n>
+ <I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
+ <template #name>
+ <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ <template #word>
+ {{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
+ </template>
+ </I18n>
</div>
<div v-else>
<!--
@@ -319,6 +329,7 @@ const isDeleted = ref(false);
const renoted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
+const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
@@ -343,13 +354,18 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
/* Overload FunctionにLintが対応していないのでコメントアウト
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
-function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
+function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
-function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' {
+function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
if (mutedWords != null) {
- if (checkWordMute(noteToCheck, $i, mutedWords)) return true;
- if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true;
- if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true;
+ const result = checkWordMute(noteToCheck, $i, mutedWords);
+ if (Array.isArray(result)) return result;
+
+ const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
+ if (Array.isArray(replyResult)) return replyResult;
+
+ const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
+ if (Array.isArray(renoteResult)) return renoteResult;
}
if (checkOnly) return false;
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index 7b45885c3e..dc8a5f59b2 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="appearNote.updatedAt" ref="menuVersionsButton" style="margin-left: 0.5em;" title="Edited" @mousedown="menuVersions()"><i class="ph-pencil-simple ph-bold ph-lg"></i></span>
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
- <SkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
+ <SkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
</div>
</header>
@@ -120,7 +120,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
- <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/>
+ <MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" :skipNoteIds="[appearNote.renote?.id]" style="margin-top: 6px;"/>
</div>
diff --git a/packages/frontend/src/components/SkNoteHeader.vue b/packages/frontend/src/components/SkNoteHeader.vue
index 6bcc30f6cb..cb50e57132 100644
--- a/packages/frontend/src/components/SkNoteHeader.vue
+++ b/packages/frontend/src/components/SkNoteHeader.vue
@@ -37,7 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ph-rocket ph-bold ph-lg"></i></span>
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ph-television ph-bold ph-lg"></i></span>
</div>
- <div :class="$style.info"><SkInstanceTicker v-if="showTicker" style="cursor: pointer;" :instance="note.user.instance" @click.stop="showOnRemote()"/></div>
+ <div :class="$style.info">
+ <SkInstanceTicker v-if="showTicker" style="cursor: pointer;" :instance="note.user.instance" :host="note.user.host" @click.stop="showOnRemote()"/>
+ </div>
</div>
</header>
<header v-else :class="$style.classicRoot">
@@ -52,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="role in note.user.badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl"/>
</div>
- <SkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" @click.stop="showOnRemote()"/>
+ <SkInstanceTicker v-if="showTicker && !isMobile && defaultStore.state.showTickerOnReplies" style="cursor: pointer; max-height: 5px; top: 3px; position: relative; margin-top: 0px !important;" :instance="note.user.instance" :host="note.user.host" @click.stop="showOnRemote()"/>
<div :class="$style.classicInfo">
<div v-if="mock">
<MkTime :time="note.createdAt" colored/>
@@ -84,6 +86,7 @@ import { popupMenu } from '@/os.js';
import { defaultStore } from '@/store.js';
import { useRouter } from '@/router/supplier.js';
import { deviceKind } from '@/scripts/device-kind.js';
+import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
const props = defineProps<{
note: Misskey.entities.Note;
diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue
index 9a1ac3aca2..2f4141b901 100644
--- a/packages/frontend/src/components/global/MkAcct.vue
+++ b/packages/frontend/src/components/global/MkAcct.vue
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
-import { toUnicode } from 'punycode/';
+import { toUnicode } from 'punycode.js';
import { host as hostRaw } from '@@/js/config.js';
import { defaultStore } from '@/store.js';
diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts
index aceed17189..9785bc0f07 100644
--- a/packages/frontend/src/components/global/MkMfm.ts
+++ b/packages/frontend/src/components/global/MkMfm.ts
@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { VNode, h, defineAsyncComponent, SetupContext, provide } from 'vue';
+import { VNode, h, defineAsyncComponent, SetupContext } from 'vue';
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
-import CkFollowMouse from '../CkFollowMouse.vue';
import { host } from '@@/js/config.js';
+import CkFollowMouse from '../CkFollowMouse.vue';
import MkUrl from '@/components/global/MkUrl.vue';
import MkTime from '@/components/global/MkTime.vue';
import MkLink from '@/components/MkLink.vue';
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
index 18c97b1bdb..1a424f349f 100644
--- a/packages/frontend/src/components/global/MkPageHeader.vue
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -57,13 +57,16 @@ import { scrollToTop } from '@@/js/scroll.js';
import { globalEvents } from '@/events.js';
import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
-import { PageHeaderItem } from '@/types/page-header.js';
+import type { PageHeaderItem } from '@/types/page-header.js';
+import type { PageMetadata } from '@/scripts/page-metadata.js';
const props = withDefaults(defineProps<{
+ overridePageMetadata?: PageMetadata;
tabs?: Tab[];
tab?: string;
actions?: PageHeaderItem[] | null;
thin?: boolean;
+ hideTitle?: boolean;
displayMyAvatar?: boolean;
displayBackButton?: boolean;
}>(), {
@@ -76,9 +79,10 @@ const emit = defineEmits<{
const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true);
-const pageMetadata = injectReactiveMetadata();
+const injectedPageMetadata = injectReactiveMetadata();
+const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value);
-const hideTitle = inject('shouldOmitHeaderTitle', false);
+const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
const thin_ = props.thin || inject('shouldHeaderThin', false);
const el = shallowRef<HTMLElement | undefined>(undefined);
@@ -87,7 +91,7 @@ const narrow = ref(false);
const hasTabs = computed(() => props.tabs.length > 0);
const hasActions = computed(() => props.actions && props.actions.length > 0);
const show = computed(() => {
- return !hideTitle || hasTabs.value || hasActions.value;
+ return !hideTitle.value || hasTabs.value || hasActions.value;
});
const preventDrag = (ev: TouchEvent) => {
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index 8cca47c1db..5196a63635 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
-import { toUnicode as decodePunycode } from 'punycode/';
+import { toUnicode as decodePunycode } from 'punycode.js';
import { url as local } from '@@/js/config.js';
import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue
new file mode 100644
index 0000000000..fd289c6cd9
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkCellTooltip.vue
@@ -0,0 +1,35 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')">
+ <div :class="$style.root">
+ {{ content }}
+ </div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from '@/components/MkTooltip.vue';
+
+defineProps<{
+ showing: boolean;
+ content: string;
+ targetElement: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'closed'): void;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ font-size: 0.9em;
+ text-align: left;
+ text-wrap: normal;
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
new file mode 100644
index 0000000000..e473b7c1af
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -0,0 +1,418 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ v-if="cell.row.using"
+ ref="rootEl"
+ class="mk_grid_td"
+ :class="$style.cell"
+ :style="{ maxWidth: cellWidth, minWidth: cellWidth }"
+ :tabindex="-1"
+ data-grid-cell
+ :data-grid-cell-row="cell.row.index"
+ :data-grid-cell-col="cell.column.index"
+ @keydown="onCellKeyDown"
+ @dblclick.prevent="onCellDoubleClick"
+>
+ <div
+ :class="[
+ $style.root,
+ [(cell.violation.valid || cell.selected) ? {} : $style.error],
+ [cell.selected ? $style.selected : {}],
+ // 行が選択されているときは範囲選択色の適用を行側に任せる
+ [(cell.ranged && !cell.row.ranged) ? $style.ranged : {}],
+ [needsContentCentering ? $style.center : {}],
+ ]"
+ >
+ <div v-if="!editing" :class="[$style.contentArea]" :style="cellType === 'boolean' ? 'justify-content: center' : ''">
+ <div ref="contentAreaEl" :class="$style.content">
+ <div v-if="cellType === 'text'">
+ {{ cell.value }}
+ </div>
+ <div v-if="cellType === 'number'">
+ {{ cell.value }}
+ </div>
+ <div v-if="cellType === 'date'">
+ {{ cell.value }}
+ </div>
+ <div v-else-if="cellType === 'boolean'">
+ <div :class="[$style.bool, {
+ [$style.boolTrue]: cell.value === true,
+ 'ti ti-check': cell.value === true,
+ }]"></div>
+ </div>
+ <div v-else-if="cellType === 'image'">
+ <img
+ :src="cell.value"
+ :alt="cell.value"
+ :class="$style.viewImage"
+ @load="emitContentSizeChanged"
+ />
+ </div>
+ </div>
+ </div>
+ <div v-else ref="inputAreaEl" :class="$style.inputArea">
+ <input
+ v-if="cellType === 'text'"
+ type="text"
+ :class="$style.editingInput"
+ :value="editingValue"
+ @input="onInputText"
+ @mousedown.stop
+ @contextmenu.stop
+ />
+ <input
+ v-if="cellType === 'number'"
+ type="number"
+ :class="$style.editingInput"
+ :value="editingValue"
+ @input="onInputText"
+ @mousedown.stop
+ @contextmenu.stop
+ />
+ <input
+ v-if="cellType === 'date'"
+ type="date"
+ :class="$style.editingInput"
+ :value="editingValue"
+ @input="onInputText"
+ @mousedown.stop
+ @contextmenu.stop
+ />
+ </div>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import { useTooltip } from '@/scripts/use-tooltip.js';
+import * as os from '@/os.js';
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
+import { GridRowSetting } from '@/components/grid/row.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginEdit', sender: GridCell): void;
+ (ev: 'operation:endEdit', sender: GridCell): void;
+ (ev: 'change:value', sender: GridCell, newValue: CellValue): void;
+ (ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
+}>();
+const props = defineProps<{
+ cell: GridCell,
+ rowSetting: GridRowSetting,
+ bus: GridEventEmitter,
+}>();
+
+const { cell, bus } = toRefs(props);
+
+const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
+const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
+const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
+
+/** 値が編集中かどうか */
+const editing = ref<boolean>(false);
+/** 編集中の値. {@link beginEditing}と{@link endEditing}内、および各inputタグやそのコールバックからの操作のみを想定する */
+const editingValue = ref<CellValue>(undefined);
+
+const cellWidth = computed(() => cell.value.column.width);
+const cellType = computed(() => cell.value.column.setting.type);
+const needsContentCentering = computed(() => {
+ switch (cellType.value) {
+ case 'boolean':
+ return true;
+ default:
+ return false;
+ }
+});
+
+watch(() => [cell.value.value], () => {
+ // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
+ nextTick(emitContentSizeChanged);
+}, { immediate: true });
+
+watch(() => cell.value.selected, () => {
+ if (cell.value.selected) {
+ requestFocus();
+ }
+});
+
+function onCellDoubleClick(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'dblclick': {
+ beginEditing(ev.target as HTMLElement);
+ break;
+ }
+ }
+}
+
+function onOutsideMouseDown(ev: MouseEvent) {
+ const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target);
+ if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) {
+ endEditing(true, false);
+ }
+}
+
+function onCellKeyDown(ev: KeyboardEvent) {
+ if (!editing.value) {
+ ev.preventDefault();
+ switch (ev.code) {
+ case 'NumpadEnter':
+ case 'Enter':
+ case 'F2': {
+ beginEditing(ev.target as HTMLElement);
+ break;
+ }
+ }
+ } else {
+ switch (ev.code) {
+ case 'Escape': {
+ endEditing(false, true);
+ break;
+ }
+ case 'NumpadEnter':
+ case 'Enter': {
+ if (!ev.isComposing) {
+ endEditing(true, true);
+ }
+ }
+ }
+ }
+}
+
+function onInputText(ev: Event) {
+ editingValue.value = (ev.target as HTMLInputElement).value;
+}
+
+function onForceRefreshContentSize() {
+ emitContentSizeChanged();
+}
+
+function registerOutsideMouseDown() {
+ unregisterOutsideMouseDown();
+ addEventListener('mousedown', onOutsideMouseDown);
+}
+
+function unregisterOutsideMouseDown() {
+ removeEventListener('mousedown', onOutsideMouseDown);
+}
+
+async function beginEditing(target: HTMLElement) {
+ if (editing.value || !cell.value.selected || !cell.value.column.setting.editable) {
+ return;
+ }
+
+ if (cell.value.column.setting.customValueEditor) {
+ emit('operation:beginEdit', cell.value);
+ const newValue = await cell.value.column.setting.customValueEditor(
+ cell.value.row,
+ cell.value.column,
+ cell.value.value,
+ target,
+ );
+ emit('operation:endEdit', cell.value);
+
+ if (newValue !== cell.value.value) {
+ emitValueChange(newValue);
+ }
+
+ requestFocus();
+ } else {
+ switch (cellType.value) {
+ case 'number':
+ case 'date':
+ case 'text': {
+ editingValue.value = cell.value.value;
+ editing.value = true;
+ registerOutsideMouseDown();
+ emit('operation:beginEdit', cell.value);
+
+ await nextTick(() => {
+ // inputの展開後にフォーカスを当てたい
+ if (inputAreaEl.value) {
+ (inputAreaEl.value.querySelector('*') as HTMLElement).focus();
+ }
+ });
+ break;
+ }
+ case 'boolean': {
+ // とくに特殊なUIは設けず、トグルするだけ
+ emitValueChange(!cell.value.value);
+ break;
+ }
+ }
+ }
+}
+
+function endEditing(applyValue: boolean, requireFocus: boolean) {
+ if (!editing.value) {
+ return;
+ }
+
+ const newValue = editingValue.value;
+ editingValue.value = undefined;
+
+ emit('operation:endEdit', cell.value);
+ unregisterOutsideMouseDown();
+
+ if (applyValue && newValue !== cell.value.value) {
+ emitValueChange(newValue);
+ }
+
+ editing.value = false;
+
+ if (requireFocus) {
+ requestFocus();
+ }
+}
+
+function requestFocus() {
+ nextTick(() => {
+ rootEl.value?.focus();
+ });
+}
+
+function emitValueChange(newValue: CellValue) {
+ const _cell = cell.value;
+ emit('change:value', _cell, newValue);
+}
+
+function emitContentSizeChanged() {
+ emit('change:contentSize', cell.value, {
+ width: contentAreaEl.value?.clientWidth ?? 0,
+ height: contentAreaEl.value?.clientHeight ?? 0,
+ });
+}
+
+useTooltip(rootEl, (showing) => {
+ if (cell.value.violation.valid) {
+ return;
+ }
+
+ const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n');
+ const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), {
+ showing,
+ content,
+ targetElement: rootEl.value!,
+ }, {
+ closed: () => {
+ result.dispose();
+ },
+ });
+});
+
+onMounted(() => {
+ bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+onUnmounted(() => {
+ bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+</script>
+
+<style module lang="scss">
+$cellHeight: 28px;
+
+.cell {
+ overflow: hidden;
+ white-space: nowrap;
+ height: $cellHeight;
+ max-height: $cellHeight;
+ min-height: $cellHeight;
+ cursor: cell;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.root {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ box-sizing: border-box;
+ height: 100%;
+
+ // selected適用時に中身がズレてしまうので、透明の線をあらかじめ引いておきたい
+ border: solid 0.5px transparent;
+
+ &.selected {
+ border: solid 0.5px var(--MI_THEME-accentLighten);
+ }
+
+ &.ranged {
+ background-color: var(--MI_THEME-accentedBg);
+ }
+
+ &.center {
+ justify-content: center;
+ }
+
+ &.error {
+ border: solid 0.5px var(--MI_THEME-error);
+ }
+}
+
+.contentArea, .inputArea {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ max-width: 100%;
+}
+
+.content {
+ display: inline-block;
+ padding: 0 8px;
+}
+
+.viewImage {
+ width: auto;
+ max-height: $cellHeight;
+ height: $cellHeight;
+ object-fit: cover;
+}
+
+.bool {
+ position: relative;
+ width: 18px;
+ height: 18px;
+ background: var(--MI_THEME-panel);
+ border: solid 2px var(--MI_THEME-divider);
+ border-radius: 4px;
+ box-sizing: border-box;
+
+ &.boolTrue {
+ border-color: var(--MI_THEME-accent);
+ background: var(--MI_THEME-accent);
+
+ &::before {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: var(--MI_THEME-fgOnAccent);
+ font-size: 12px;
+ line-height: 18px;
+ }
+ }
+}
+
+.editingInput {
+ padding: 0 8px;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-height: $cellHeight - 2;
+ max-height: $cellHeight - 2;
+ height: $cellHeight - 2;
+ outline: none;
+ border: none;
+ font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
+}
+
+</style>
diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue
new file mode 100644
index 0000000000..280a14bc4a
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkDataRow.vue
@@ -0,0 +1,72 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="mk_grid_tr"
+ :class="[
+ $style.row,
+ row.ranged ? $style.ranged : {},
+ ...(row.additionalStyles ?? []).map(it => it.className ?? {}),
+ ]"
+ :style="[
+ ...(row.additionalStyles ?? []).map(it => it.style ?? {}),
+ ]"
+ :data-grid-row="row.index"
+>
+ <MkNumberCell
+ v-if="setting.showNumber"
+ :content="(row.index + 1).toString()"
+ :row="row"
+ />
+ <MkDataCell
+ v-for="cell in cells"
+ :key="cell.address.col"
+ :vIf="cell.column.setting.type !== 'hidden'"
+ :cell="cell"
+ :rowSetting="setting"
+ :bus="bus"
+ @operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
+ @operation:endEdit="(sender) => emit('operation:endEdit', sender)"
+ @change:value="(sender, newValue) => emit('change:value', sender, newValue)"
+ @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
+ />
+</div>
+</template>
+
+<script setup lang="ts">
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import MkDataCell from '@/components/grid/MkDataCell.vue';
+import MkNumberCell from '@/components/grid/MkNumberCell.vue';
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridRow, GridRowSetting } from '@/components/grid/row.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginEdit', sender: GridCell): void;
+ (ev: 'operation:endEdit', sender: GridCell): void;
+ (ev: 'change:value', sender: GridCell, newValue: CellValue): void;
+ (ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
+}>();
+defineProps<{
+ row: GridRow,
+ cells: GridCell[],
+ setting: GridRowSetting,
+ bus: GridEventEmitter,
+}>();
+
+</script>
+
+<style module lang="scss">
+.row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ width: fit-content;
+
+ &.ranged {
+ background-color: var(--MI_THEME-accentedBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts
new file mode 100644
index 0000000000..5801012f15
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts
@@ -0,0 +1,223 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import { ref } from 'vue';
+import { commonHandlers } from '../../../.storybook/mocks.js';
+import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js';
+import MkGrid from './MkGrid.vue';
+import { GridContext, GridEvent } from '@/components/grid/grid-event.js';
+import { DataSource, GridSetting } from '@/components/grid/grid.js';
+import { GridColumnSetting } from '@/components/grid/column.js';
+
+function d(p: {
+ check?: boolean,
+ name?: string,
+ email?: string,
+ age?: number,
+ birthday?: string,
+ gender?: string,
+ country?: string,
+ reportCount?: number,
+ createdAt?: string,
+}, seed: string) {
+ const prefix = text(10, seed);
+
+ return {
+ check: p.check ?? boolean(seed),
+ name: p.name ?? `${firstName(seed)} ${lastName(seed)}`,
+ email: p.email ?? `${prefix}@example.com`,
+ age: p.age ?? integer(20, 80, seed),
+ birthday: date({}, seed).toISOString(),
+ gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed),
+ country: p.country ?? country(seed),
+ reportCount: p.reportCount ?? integer(0, 9999, seed),
+ createdAt: p.createdAt ?? date({}, seed).toISOString(),
+ };
+}
+
+const defaultCols: GridColumnSetting[] = [
+ { bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 },
+ { bindTo: 'name', title: 'Name', type: 'text', width: 'auto' },
+ { bindTo: 'email', title: 'Email', type: 'text', width: 'auto' },
+ { bindTo: 'age', title: 'Age', type: 'number', width: 50 },
+ { bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' },
+ { bindTo: 'gender', title: 'Gender', type: 'text', width: 80 },
+ { bindTo: 'country', title: 'Country', type: 'text', width: 120 },
+ { bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' },
+ { bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' },
+];
+
+function createArgs(overrides?: { settings?: Partial<GridSetting>, data?: DataSource[] }) {
+ const refData = ref<ReturnType<typeof d>[]>([]);
+ for (let i = 0; i < 100; i++) {
+ refData.value.push(d({}, i.toString()));
+ }
+
+ return {
+ settings: {
+ row: overrides?.settings?.row,
+ cols: [
+ ...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true),
+ ...overrides?.settings?.cols ?? [],
+ ],
+ cells: overrides?.settings?.cells,
+ },
+ data: refData.value,
+ };
+}
+
+function createRender(params: { settings: GridSetting, data: DataSource[] }) {
+ return {
+ render(args) {
+ return {
+ components: {
+ MkGrid,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ data() {
+ return {
+ data: args.data,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...args,
+ };
+ },
+ events() {
+ return {
+ event: (event: GridEvent, context: GridContext) => {
+ switch (event.type) {
+ case 'cell-value-change': {
+ args.data[event.row.index][event.column.setting.bindTo] = event.newValue;
+ }
+ }
+ },
+ };
+ },
+ },
+ template: '<div style="padding:20px"><MkGrid v-bind="props" v-on="events" /></div>',
+ };
+ },
+ args: {
+ ...params,
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ ],
+ },
+ },
+ } satisfies StoryObj<typeof MkGrid>;
+}
+
+export const Default = createRender(createArgs());
+
+export const NoNumber = createRender(createArgs({
+ settings: {
+ row: {
+ showNumber: false,
+ },
+ },
+}));
+
+export const NoSelectable = createRender(createArgs({
+ settings: {
+ row: {
+ selectable: false,
+ },
+ },
+}));
+
+export const Editable = createRender(createArgs({
+ settings: {
+ cols: defaultCols.map(col => ({ ...col, editable: true })),
+ },
+}));
+
+export const AdditionalRowStyle = createRender(createArgs({
+ settings: {
+ cols: defaultCols.map(col => ({ ...col, editable: true })),
+ row: {
+ styleRules: [
+ {
+ condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean,
+ applyStyle: {
+ style: {
+ backgroundColor: 'lightgray',
+ },
+ },
+ },
+ ],
+ },
+ },
+}));
+
+export const ContextMenu = createRender(createArgs({
+ settings: {
+ cols: [
+ {
+ bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [
+ {
+ type: 'button',
+ text: 'Check All',
+ action: () => {
+ for (const d of ContextMenu.args.data) {
+ d.check = true;
+ }
+ },
+ },
+ {
+ type: 'button',
+ text: 'Uncheck All',
+ action: () => {
+ for (const d of ContextMenu.args.data) {
+ d.check = false;
+ }
+ },
+ },
+ ],
+ },
+ ],
+ row: {
+ contextMenuFactory: (row, context) => [
+ {
+ type: 'button',
+ text: 'Delete',
+ action: () => {
+ const idxes = context.rangedRows.map(r => r.index);
+ const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i));
+
+ ContextMenu.args.data.splice(0);
+ ContextMenu.args.data.push(...newData);
+ },
+ },
+ ],
+ },
+ cells: {
+ contextMenuFactory: (col, row, value, context) => [
+ {
+ type: 'button',
+ text: 'Delete',
+ action: () => {
+ for (const cell of context.rangedCells) {
+ ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined;
+ }
+ },
+ },
+ ],
+ },
+ },
+}));
diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue
new file mode 100644
index 0000000000..4dbd4ebcae
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkGrid.vue
@@ -0,0 +1,1374 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ class="mk_grid_border"
+ :class="[$style.grid, {
+ [$style.noOverflowHandling]: rootSetting.noOverflowStyle,
+ 'mk_grid_root_rounded': rootSetting.rounded,
+ 'mk_grid_root_border': rootSetting.outerBorder,
+ }]"
+ @mousedown.prevent="onMouseDown"
+ @keydown="onKeyDown"
+ @contextmenu.prevent.stop="onContextMenu"
+>
+ <div class="mk_grid_thead">
+ <MkHeaderRow
+ :columns="columns"
+ :gridSetting="rowSetting"
+ :bus="bus"
+ @operation:beginWidthChange="onHeaderCellWidthBeginChange"
+ @operation:endWidthChange="onHeaderCellWidthEndChange"
+ @operation:widthLargest="onHeaderCellWidthLargest"
+ @change:width="onHeaderCellChangeWidth"
+ @change:contentSize="onHeaderCellChangeContentSize"
+ />
+ </div>
+ <div class="mk_grid_tbody">
+ <MkDataRow
+ v-for="row in rows"
+ v-show="row.using"
+ :key="row.index"
+ :row="row"
+ :cells="cells[row.index].cells"
+ :setting="rowSetting"
+ :bus="bus"
+ :using="row.using"
+ :class="[lastLine === row.index ? 'last_row' : '']"
+ @operation:beginEdit="onCellEditBegin"
+ @operation:endEdit="onCellEditEnd"
+ @change:value="onChangeCellValue"
+ @change:contentSize="onChangeCellContentSize"
+ />
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, toRefs, watch } from 'vue';
+import { DataSource, GridEventEmitter, GridSetting, GridState, Size } from '@/components/grid/grid.js';
+import MkDataRow from '@/components/grid/MkDataRow.vue';
+import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
+import { cellValidation } from '@/components/grid/cell-validators.js';
+import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell, resetCell } from '@/components/grid/cell.js';
+import {
+ copyGridDataToClipboard,
+ equalCellAddress,
+ getCellAddress,
+ getCellElement,
+ pasteToGridFromClipboard,
+ removeDataFromGrid,
+} from '@/components/grid/grid-utils.js';
+import { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
+import { GridContext, GridEvent } from '@/components/grid/grid-event.js';
+import { createColumn, GridColumn } from '@/components/grid/column.js';
+import { createRow, defaultGridRowSetting, GridRow, GridRowSetting, resetRow } from '@/components/grid/row.js';
+import { handleKeyEvent } from '@/scripts/key-event.js';
+
+type RowHolder = {
+ row: GridRow,
+ cells: GridCell[],
+ origin: DataSource,
+}
+
+const emit = defineEmits<{
+ (ev: 'event', event: GridEvent, context: GridContext): void;
+}>();
+
+const props = defineProps<{
+ settings: GridSetting;
+ data: DataSource[];
+}>();
+
+const rootSetting: Required<GridSetting['root']> = {
+ noOverflowStyle: false,
+ rounded: true,
+ outerBorder: true,
+ ...props.settings.root,
+};
+
+// non-reactive
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const rowSetting: Required<GridRowSetting> = {
+ ...defaultGridRowSetting,
+ ...props.settings.row,
+};
+
+// non-reactive
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const columnSettings = props.settings.cols;
+
+// non-reactive
+const cellSettings = props.settings.cells ?? {};
+
+const { data } = toRefs(props);
+
+// #region Event Definitions
+// region Event Definitions
+
+/**
+ * grid -> 各子コンポーネントのイベント経路を担う{@link GridEventEmitter}。おもにpropsでの伝搬が難しいイベントを伝搬するために使用する。
+ * 子コンポーネント -> gridのイベントでは原則使用せず、{@link emit}を使用する。
+ */
+const bus = new GridEventEmitter();
+/**
+ * テーブルコンポーネントのリサイズイベントを監視するための{@link ResizeObserver}。
+ * 表示切替を検知し、サイズの再計算要求を発行するために使用する(マウント時にコンテンツが表示されていない場合、初手のサイズの自動計算が正常に働かないため)
+ *
+ * {@link setTimeout}を経由している理由は、{@link onResize}の中でサイズ再計算要求→サイズ変更が発生するとループとみなされ、
+ * 「ResizeObserver loop completed with undelivered notifications.」という警告が発生するため(再計算が完全に終われば通知は発生しなくなるので実際にはループしない)
+ *
+ * @see {@link onResize}
+ */
+const resizeObserver = new ResizeObserver((entries) => setTimeout(() => onResize(entries)));
+
+const rootEl = ref<InstanceType<typeof HTMLTableElement>>();
+/**
+ * グリッドの最も上位にある状態。
+ */
+const state = ref<GridState>('normal');
+/**
+ * グリッドの列定義。列定義の元の設定値は非リアクティブなので、初期値を生成して以降は変更しない。
+ */
+const columns = ref<GridColumn[]>(columnSettings.map(createColumn));
+/**
+ * グリッドの行定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。
+ */
+const rows = ref<GridRow[]>([]);
+/**
+ * グリッドのセル定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。
+ */
+const cells = ref<RowHolder[]>([]);
+
+/**
+ * mousemoveイベントが発生した際に、イベントから取得したセルアドレスを保持するための変数。
+ * セルアドレスが変わった瞬間にイベントを起こしたい時のために前回値として使用する。
+ */
+const previousCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE);
+/**
+ * 編集中のセルのアドレスを保持するための変数。
+ */
+const editingCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE);
+/**
+ * 列の範囲選択をする際の開始地点となるインデックスを保持するための変数。
+ * この開始地点からマウスが動いた地点までの範囲を選択する。
+ */
+const firstSelectionColumnIdx = ref<number>(CELL_ADDRESS_NONE.col);
+/**
+ * 行の範囲選択をする際の開始地点となるインデックスを保持するための変数。
+ * この開始地点からマウスが動いた地点までの範囲を選択する。
+ */
+const firstSelectionRowIdx = ref<number>(CELL_ADDRESS_NONE.row);
+
+/**
+ * 選択状態のセルを取得するための計算プロパティ。選択状態とは{@link GridCell.selected}がtrueのセルのこと。
+ */
+const selectedCell = computed(() => {
+ const selected = cells.value.flatMap(it => it.cells).filter(it => it.selected);
+ return selected.length > 0 ? selected[0] : undefined;
+});
+/**
+ * 範囲選択状態のセルを取得するための計算プロパティ。範囲選択状態とは{@link GridCell.ranged}がtrueのセルのこと。
+ */
+const rangedCells = computed(() => cells.value.flatMap(it => it.cells).filter(it => it.ranged));
+/**
+ * 範囲選択状態のセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。
+ */
+const rangedBounds = computed(() => {
+ const _cells = rangedCells.value;
+ const _cols = _cells.map(it => it.address.col);
+ const _rows = _cells.map(it => it.address.row);
+
+ const leftTop = {
+ col: Math.min(..._cols),
+ row: Math.min(..._rows),
+ };
+ const rightBottom = {
+ col: Math.max(..._cols),
+ row: Math.max(..._rows),
+ };
+
+ return {
+ leftTop,
+ rightBottom,
+ };
+});
+/**
+ * グリッドの中で使用可能なセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。
+ */
+const availableBounds = computed(() => {
+ const leftTop = {
+ col: 0,
+ row: 0,
+ };
+ const rightBottom = {
+ col: Math.max(...columns.value.map(it => it.index)),
+ row: Math.max(...rows.value.filter(it => it.using).map(it => it.index)),
+ };
+ return { leftTop, rightBottom };
+});
+/**
+ * 範囲選択状態の行を取得するための計算プロパティ。範囲選択状態とは{@link GridRow.ranged}がtrueの行のこと。
+ */
+const rangedRows = computed(() => rows.value.filter(it => it.ranged));
+
+const lastLine = computed(() => rows.value.filter(it => it.using).length - 1);
+
+// endregion
+// #endregion
+
+watch(data, patchData, { deep: true });
+
+if (_DEV_) {
+ watch(state, (value, oldValue) => {
+ console.log(`[grid][state] ${oldValue} -> ${value}`);
+ });
+}
+
+// #region Event Handlers
+// region Event Handlers
+
+function onResize(entries: ResizeObserverEntry[]) {
+ if (entries.length !== 1 || entries[0].target !== rootEl.value) {
+ return;
+ }
+
+ const contentRect = entries[0].contentRect;
+ if (_DEV_) {
+ console.log(`[grid][resize] contentRect: ${contentRect.width}x${contentRect.height}`);
+ }
+
+ switch (state.value) {
+ case 'hidden': {
+ if (contentRect.width > 0 && contentRect.height > 0) {
+ // 先に状態を変更しておき、再計算要求が複数回走らないようにする
+ state.value = 'normal';
+
+ // 選択状態が狂うかもしれないので解除しておく
+ unSelectionRangeAll();
+
+ // 再計算要求を発行。各セル側で最低限必要な横幅を算出し、emitで返してくるようになっている
+ bus.emit('forceRefreshContentSize');
+ }
+ break;
+ }
+ default: {
+ if (contentRect.width === 0 || contentRect.height === 0) {
+ state.value = 'hidden';
+ }
+ break;
+ }
+ }
+}
+
+function onKeyDown(ev: KeyboardEvent) {
+ const { ctrlKey, shiftKey, code } = ev;
+ if (_DEV_) {
+ console.log(`[grid][key] ctrl: ${ctrlKey}, shift: ${shiftKey}, code: ${code}`);
+ }
+
+ function updateSelectionRange(newBounds: { leftTop: CellAddress, rightBottom: CellAddress }) {
+ unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom);
+ expandCellRange(newBounds.leftTop, newBounds.rightBottom);
+ }
+
+ switch (state.value) {
+ case 'normal': {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const selectedCellAddress = selectedCell.value?.address ?? CELL_ADDRESS_NONE;
+ const max = availableBounds.value;
+ const bounds = rangedBounds.value;
+
+ handleKeyEvent(ev, [
+ {
+ code: 'Delete', handler: () => {
+ if (rangedRows.value.length > 0) {
+ if (rowSetting.events.delete) {
+ rowSetting.events.delete(rangedRows.value);
+ }
+ } else {
+ const context = createContext();
+ removeDataFromGrid(context, (cell) => {
+ emitCellValue(cell, undefined);
+ });
+ }
+ },
+ },
+ {
+ code: 'KeyC', modifiers: ['Control'], handler: () => {
+ const context = createContext();
+ copyGridDataToClipboard(data.value, context);
+ },
+ },
+ {
+ code: 'KeyV', modifiers: ['Control'], handler: async () => {
+ const _cells = cells.value;
+ const context = createContext();
+ await pasteToGridFromClipboard(context, (row, col, parsedValue) => {
+ emitCellValue(_cells[row.index].cells[col.index], parsedValue);
+ });
+ },
+ },
+ {
+ code: 'ArrowRight', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: selectedCellAddress.col, row: bounds.leftTop.row },
+ rightBottom: { col: max.rightBottom.col, row: bounds.rightBottom.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowLeft', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: max.leftTop.col, row: bounds.leftTop.row },
+ rightBottom: { col: selectedCellAddress.col, row: bounds.rightBottom.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowUp', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: bounds.leftTop.col, row: max.leftTop.row },
+ rightBottom: { col: bounds.rightBottom.col, row: selectedCellAddress.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowDown', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: bounds.leftTop.col, row: selectedCellAddress.row },
+ rightBottom: { col: bounds.rightBottom.col, row: max.rightBottom.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowRight', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: bounds.leftTop.col < selectedCellAddress.col
+ ? bounds.leftTop.col + 1
+ : selectedCellAddress.col,
+ row: bounds.leftTop.row,
+ },
+ rightBottom: {
+ col: (bounds.rightBottom.col > selectedCellAddress.col || bounds.leftTop.col === selectedCellAddress.col)
+ ? bounds.rightBottom.col + 1
+ : selectedCellAddress.col,
+ row: bounds.rightBottom.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowLeft', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: (bounds.leftTop.col < selectedCellAddress.col || bounds.rightBottom.col === selectedCellAddress.col)
+ ? bounds.leftTop.col - 1
+ : selectedCellAddress.col,
+ row: bounds.leftTop.row,
+ },
+ rightBottom: {
+ col: bounds.rightBottom.col > selectedCellAddress.col
+ ? bounds.rightBottom.col - 1
+ : selectedCellAddress.col,
+ row: bounds.rightBottom.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowUp', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: bounds.leftTop.col,
+ row: (bounds.leftTop.row < selectedCellAddress.row || bounds.rightBottom.row === selectedCellAddress.row)
+ ? bounds.leftTop.row - 1
+ : selectedCellAddress.row,
+ },
+ rightBottom: {
+ col: bounds.rightBottom.col,
+ row: bounds.rightBottom.row > selectedCellAddress.row
+ ? bounds.rightBottom.row - 1
+ : selectedCellAddress.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowDown', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: bounds.leftTop.col,
+ row: bounds.leftTop.row < selectedCellAddress.row
+ ? bounds.leftTop.row + 1
+ : selectedCellAddress.row,
+ },
+ rightBottom: {
+ col: bounds.rightBottom.col,
+ row: (bounds.rightBottom.row > selectedCellAddress.row || bounds.leftTop.row === selectedCellAddress.row)
+ ? bounds.rightBottom.row + 1
+ : selectedCellAddress.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowDown', handler: () => {
+ selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row + 1 });
+ },
+ },
+ {
+ code: 'ArrowUp', handler: () => {
+ selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row - 1 });
+ },
+ },
+ {
+ code: 'ArrowRight', handler: () => {
+ selectionCell({ col: selectedCellAddress.col + 1, row: selectedCellAddress.row });
+ },
+ },
+ {
+ code: 'ArrowLeft', handler: () => {
+ selectionCell({ col: selectedCellAddress.col - 1, row: selectedCellAddress.row });
+ },
+ },
+ ]);
+
+ break;
+ }
+ }
+}
+
+function onMouseDown(ev: MouseEvent) {
+ switch (ev.button) {
+ case 0: {
+ onLeftMouseDown(ev);
+ break;
+ }
+ case 2: {
+ onRightMouseDown(ev);
+ break;
+ }
+ }
+}
+
+function onLeftMouseDown(ev: MouseEvent) {
+ const cellAddress = getCellAddress(ev.target as HTMLElement);
+ if (_DEV_) {
+ console.log(`[grid][mouse-left] state:${state.value}, button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`);
+ }
+
+ switch (state.value) {
+ case 'cellEditing': {
+ if (availableCellAddress(cellAddress) && !equalCellAddress(editingCellAddress.value, cellAddress)) {
+ selectionCell(cellAddress);
+ }
+ break;
+ }
+ case 'normal': {
+ if (availableCellAddress(cellAddress)) {
+ if (ev.shiftKey && selectedCell.value && !equalCellAddress(cellAddress, selectedCell.value.address)) {
+ const selectedCellAddress = selectedCell.value.address;
+
+ const leftTop = {
+ col: Math.min(selectedCellAddress.col, cellAddress.col),
+ row: Math.min(selectedCellAddress.row, cellAddress.row),
+ };
+
+ const rightBottom = {
+ col: Math.max(selectedCellAddress.col, cellAddress.col),
+ row: Math.max(selectedCellAddress.row, cellAddress.row),
+ };
+
+ unSelectionRangeAll();
+ expandCellRange(leftTop, rightBottom);
+
+ cells.value[selectedCellAddress.row].cells[selectedCellAddress.col].selected = true;
+ } else {
+ selectionCell(cellAddress);
+ }
+
+ previousCellAddress.value = cellAddress;
+
+ registerMouseUp();
+ registerMouseMove();
+ state.value = 'cellSelecting';
+ } else if (isColumnHeaderCellAddress(cellAddress)) {
+ if (ev.shiftKey) {
+ const rangedColumnIndexes = rangedCells.value.map(it => it.address.col);
+ const targetColumnIndexes = [cellAddress.col, ...rangedColumnIndexes];
+ unSelectionRangeAll();
+
+ const leftTop = {
+ col: Math.min(...targetColumnIndexes),
+ row: 0,
+ };
+
+ const rightBottom = {
+ col: Math.max(...targetColumnIndexes),
+ row: cells.value.length - 1,
+ };
+
+ expandCellRange(leftTop, rightBottom);
+
+ if (rangedColumnIndexes.length === 0) {
+ firstSelectionColumnIdx.value = cellAddress.col;
+ } else {
+ if (cellAddress.col > Math.min(...rangedColumnIndexes)) {
+ firstSelectionColumnIdx.value = Math.min(...rangedColumnIndexes);
+ } else {
+ firstSelectionColumnIdx.value = Math.max(...rangedColumnIndexes);
+ }
+ }
+ } else {
+ unSelectionRangeAll();
+
+ const colCells = cells.value.map(row => row.cells[cellAddress.col]);
+ selectionRange(...colCells.map(cell => cell.address));
+
+ firstSelectionColumnIdx.value = cellAddress.col;
+ }
+
+ registerMouseUp();
+ registerMouseMove();
+ previousCellAddress.value = cellAddress;
+ state.value = 'colSelecting';
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+ } else if (isRowNumberCellAddress(cellAddress)) {
+ if (ev.shiftKey) {
+ const rangedRowIndexes = rangedRows.value.map(it => it.index);
+ const targetRowIndexes = [cellAddress.row, ...rangedRowIndexes];
+ unSelectionRangeAll();
+
+ const leftTop = {
+ col: 0,
+ row: Math.min(...targetRowIndexes),
+ };
+
+ const rightBottom = {
+ col: Math.min(...cells.value.map(it => it.cells.length - 1)),
+ row: Math.max(...targetRowIndexes),
+ };
+
+ expandCellRange(leftTop, rightBottom);
+ expandRowRange(Math.min(...targetRowIndexes), Math.max(...targetRowIndexes));
+
+ if (rangedRowIndexes.length === 0) {
+ firstSelectionRowIdx.value = cellAddress.row;
+ } else {
+ if (cellAddress.col > Math.min(...rangedRowIndexes)) {
+ firstSelectionRowIdx.value = Math.min(...rangedRowIndexes);
+ } else {
+ firstSelectionRowIdx.value = Math.max(...rangedRowIndexes);
+ }
+ }
+ } else {
+ unSelectionRangeAll();
+ const rowCells = cells.value[cellAddress.row].cells;
+ selectionRange(...rowCells.map(cell => cell.address));
+ expandRowRange(cellAddress.row, cellAddress.row);
+
+ firstSelectionRowIdx.value = cellAddress.row;
+ }
+
+ registerMouseUp();
+ registerMouseMove();
+ previousCellAddress.value = cellAddress;
+ state.value = 'rowSelecting';
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+ }
+ break;
+ }
+ }
+}
+
+function onRightMouseDown(ev: MouseEvent) {
+ const cellAddress = getCellAddress(ev.target as HTMLElement);
+ if (_DEV_) {
+ console.log(`[grid][mouse-right] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`);
+ }
+
+ switch (state.value) {
+ case 'normal': {
+ if (!availableCellAddress(cellAddress)) {
+ return;
+ }
+
+ const _rangedCells = [...rangedCells.value];
+ if (!_rangedCells.some(it => equalCellAddress(it.address, cellAddress))) {
+ // 範囲選択外を右クリックした場合は、範囲選択を解除(範囲選択内であれば範囲選択を維持する)
+ selectionCell(cellAddress);
+ }
+
+ break;
+ }
+ }
+}
+
+function onMouseMove(ev: MouseEvent) {
+ ev.preventDefault();
+
+ const targetCellAddress = getCellAddress(ev.target as HTMLElement);
+ if (equalCellAddress(previousCellAddress.value, targetCellAddress)) {
+ // セルが変わるまでイベントを起こしたくない
+ return;
+ }
+
+ if (_DEV_) {
+ console.log(`[grid][mouse-move] button: ${ev.button}, cell: ${targetCellAddress.row}x${targetCellAddress.col}`);
+ }
+
+ switch (state.value) {
+ case 'cellSelecting': {
+ const selectedCellAddress = selectedCell.value?.address;
+ if (!availableCellAddress(targetCellAddress) || !selectedCellAddress) {
+ // 正しいセル範囲ではない
+ return;
+ }
+
+ const leftTop = {
+ col: Math.min(targetCellAddress.col, selectedCellAddress.col),
+ row: Math.min(targetCellAddress.row, selectedCellAddress.row),
+ };
+
+ const rightBottom = {
+ col: Math.max(targetCellAddress.col, selectedCellAddress.col),
+ row: Math.max(targetCellAddress.row, selectedCellAddress.row),
+ };
+
+ // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする
+ unSelectionOutOfRange(leftTop, rightBottom);
+ expandCellRange(leftTop, rightBottom);
+ previousCellAddress.value = targetCellAddress;
+
+ break;
+ }
+ case 'colSelecting': {
+ if (!isColumnHeaderCellAddress(targetCellAddress) || previousCellAddress.value.col === targetCellAddress.col) {
+ // セルが変わるまでイベントを起こしたくない
+ return;
+ }
+
+ const leftTop = {
+ col: Math.min(targetCellAddress.col, firstSelectionColumnIdx.value),
+ row: 0,
+ };
+
+ const rightBottom = {
+ col: Math.max(targetCellAddress.col, firstSelectionColumnIdx.value),
+ row: cells.value.length - 1,
+ };
+
+ // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする
+ unSelectionOutOfRange(leftTop, rightBottom);
+ expandCellRange(leftTop, rightBottom);
+ previousCellAddress.value = targetCellAddress;
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+
+ break;
+ }
+ case 'rowSelecting': {
+ if (!isRowNumberCellAddress(targetCellAddress) || previousCellAddress.value.row === targetCellAddress.row) {
+ // セルが変わるまでイベントを起こしたくない
+ return;
+ }
+
+ const leftTop = {
+ col: 0,
+ row: Math.min(targetCellAddress.row, firstSelectionRowIdx.value),
+ };
+
+ const rightBottom = {
+ col: Math.min(...cells.value.map(it => it.cells.length - 1)),
+ row: Math.max(targetCellAddress.row, firstSelectionRowIdx.value),
+ };
+
+ // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする
+ unSelectionOutOfRange(leftTop, rightBottom);
+ expandCellRange(leftTop, rightBottom);
+
+ // 行も同様に
+ const rangedRowIndexes = [rows.value[targetCellAddress.row].index, ...rangedRows.value.map(it => it.index)];
+ expandRowRange(Math.min(...rangedRowIndexes), Math.max(...rangedRowIndexes));
+
+ previousCellAddress.value = targetCellAddress;
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+
+ break;
+ }
+ }
+}
+
+function onMouseUp(ev: MouseEvent) {
+ ev.preventDefault();
+ switch (state.value) {
+ case 'rowSelecting':
+ case 'colSelecting':
+ case 'cellSelecting': {
+ unregisterMouseUp();
+ unregisterMouseMove();
+ state.value = 'normal';
+ previousCellAddress.value = CELL_ADDRESS_NONE;
+ break;
+ }
+ }
+}
+
+function onContextMenu(ev: MouseEvent) {
+ const cellAddress = getCellAddress(ev.target as HTMLElement);
+ if (_DEV_) {
+ console.log(`[grid][context-menu] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`);
+ }
+
+ const context = createContext();
+ const menuItems = Array.of<MenuItem>();
+ switch (true) {
+ // 通常セルのコンテキストメニュー作成
+ case availableCellAddress(cellAddress): {
+ const cell = cells.value[cellAddress.row].cells[cellAddress.col];
+ if (cell.setting.contextMenuFactory) {
+ menuItems.push(...cell.setting.contextMenuFactory(cell.column, cell.row, cell.value, context));
+ }
+ break;
+ }
+ // 列ヘッダセルのコンテキストメニュー作成
+ case isColumnHeaderCellAddress(cellAddress): {
+ const col = columns.value[cellAddress.col];
+ if (col.setting.contextMenuFactory) {
+ menuItems.push(...col.setting.contextMenuFactory(col, context));
+ }
+ break;
+ }
+ // 行ヘッダセルのコンテキストメニュー作成
+ case isRowNumberCellAddress(cellAddress): {
+ const row = rows.value[cellAddress.row];
+ if (row.setting.contextMenuFactory) {
+ menuItems.push(...row.setting.contextMenuFactory(row, context));
+ }
+ break;
+ }
+ }
+
+ if (menuItems.length > 0) {
+ os.contextMenu(menuItems, ev);
+ }
+}
+
+function onCellEditBegin(sender: GridCell) {
+ state.value = 'cellEditing';
+ editingCellAddress.value = sender.address;
+ for (const cell of cells.value.flatMap(it => it.cells)) {
+ if (cell.address.col !== sender.address.col || cell.address.row !== sender.address.row) {
+ // 編集状態となったセル以外は全部選択解除
+ cell.selected = false;
+ }
+ }
+}
+
+function onCellEditEnd() {
+ editingCellAddress.value = CELL_ADDRESS_NONE;
+ state.value = 'normal';
+}
+
+function onChangeCellValue(sender: GridCell, newValue: CellValue) {
+ applyRowRules([sender]);
+ emitCellValue(sender, newValue);
+}
+
+function onChangeCellContentSize(sender: GridCell, contentSize: Size) {
+ const _cells = cells.value;
+ if (_cells.length > sender.address.row && _cells[sender.address.row].cells.length > sender.address.col) {
+ const currentSize = _cells[sender.address.row].cells[sender.address.col].contentSize;
+ if (currentSize.width !== contentSize.width || currentSize.height !== contentSize.height) {
+ // 通常セルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用)
+ _cells[sender.address.row].cells[sender.address.col].contentSize = contentSize;
+
+ if (sender.column.setting.width === 'auto') {
+ calcLargestCellWidth(sender.column);
+ }
+ }
+ }
+}
+
+function onHeaderCellWidthBeginChange() {
+ switch (state.value) {
+ case 'normal': {
+ state.value = 'colResizing';
+ break;
+ }
+ }
+}
+
+function onHeaderCellWidthEndChange() {
+ switch (state.value) {
+ case 'colResizing': {
+ state.value = 'normal';
+ break;
+ }
+ }
+}
+
+function onHeaderCellChangeWidth(sender: GridColumn, width: string) {
+ switch (state.value) {
+ case 'colResizing': {
+ const column = columns.value[sender.index];
+ column.width = width;
+ break;
+ }
+ }
+}
+
+function onHeaderCellChangeContentSize(sender: GridColumn, newSize: Size) {
+ switch (state.value) {
+ case 'normal': {
+ const currentSize = columns.value[sender.index].contentSize;
+ if (currentSize.width !== newSize.width || currentSize.height !== newSize.height) {
+ // ヘッダセルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用)
+ columns.value[sender.index].contentSize = newSize;
+
+ if (sender.setting.width === 'auto') {
+ calcLargestCellWidth(sender);
+ }
+ }
+ break;
+ }
+ }
+}
+
+function onHeaderCellWidthLargest(sender: GridColumn) {
+ switch (state.value) {
+ case 'normal': {
+ calcLargestCellWidth(sender);
+ break;
+ }
+ }
+}
+
+// endregion
+// #endregion
+
+// #region Methods
+// region Methods
+
+/**
+ * カラム内のコンテンツを表示しきるために必要な横幅と、各セルのコンテンツを表示しきるために必要な横幅を比較し、大きい方を列全体の横幅として採用する。
+ */
+function calcLargestCellWidth(column: GridColumn) {
+ const _cells = cells.value;
+ const largestColumnWidth = columns.value[column.index].contentSize.width;
+
+ const largestCellWidth = (_cells.length > 0)
+ ? _cells
+ .map(row => row.cells[column.index])
+ .reduce(
+ (acc, value) => Math.max(acc, value.contentSize.width),
+ 0,
+ )
+ : 0;
+
+ if (_DEV_) {
+ console.log(`[grid][calc-largest] idx:${column.setting.bindTo}, col:${largestColumnWidth}, cell:${largestCellWidth}`);
+ }
+
+ column.width = `${Math.max(largestColumnWidth, largestCellWidth)}px`;
+}
+
+/**
+ * {@link emit}を使用してイベントを発行する。
+ */
+function emitGridEvent(ev: GridEvent) {
+ const currentState: GridContext = {
+ selectedCell: selectedCell.value,
+ rangedCells: rangedCells.value,
+ rangedRows: rangedRows.value,
+ randedBounds: rangedBounds.value,
+ availableBounds: availableBounds.value,
+ state: state.value,
+ rows: rows.value,
+ columns: columns.value,
+ };
+
+ emit(
+ 'event',
+ ev,
+ currentState,
+ );
+}
+
+/**
+ * 親コンポーネントに新しい値を通知する。
+ * 新しい値は、イベント通知→元データへの反映→再計算(バリデーション含む)→再描画の流れで反映される。
+ */
+function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
+ const cellAddress = 'address' in sender ? sender.address : sender;
+ const cell = cells.value[cellAddress.row].cells[cellAddress.col];
+
+ emitGridEvent({
+ type: 'cell-value-change',
+ column: cell.column,
+ row: cell.row,
+ oldValue: cell.value,
+ newValue: newValue,
+ });
+
+ if (_DEV_) {
+ console.log(`[grid][cell-value] row:${cell.row}, col:${cell.column.index}, value:${newValue}`);
+ }
+}
+
+/**
+ * {@link target}のセルを選択状態にする。
+ * その際、{@link target}以外の行およびセルの範囲選択状態を解除する。
+ */
+function selectionCell(target: CellAddress) {
+ if (!availableCellAddress(target)) {
+ return;
+ }
+
+ unSelectionRangeAll();
+
+ const _cells = cells.value;
+ _cells[target.row].cells[target.col].selected = true;
+ _cells[target.row].cells[target.col].ranged = true;
+}
+
+/**
+ * {@link targets}のセルを範囲選択状態にする。
+ */
+function selectionRange(...targets: CellAddress[]) {
+ const _cells = cells.value;
+ for (const target of targets) {
+ const row = _cells[target.row];
+ if (row.row.using) {
+ row.cells[target.col].ranged = true;
+ }
+ }
+}
+
+/**
+ * 行およびセルの範囲選択状態をすべて解除する。
+ */
+function unSelectionRangeAll() {
+ const _cells = rangedCells.value;
+ for (const cell of _cells) {
+ cell.selected = false;
+ cell.ranged = false;
+ }
+
+ const _rows = rows.value.filter(it => it.using);
+ for (const row of _rows) {
+ row.ranged = false;
+ }
+}
+
+/**
+ * {@link leftTop}から{@link rightBottom}の範囲外にあるセルを範囲選択状態から外す。
+ */
+function unSelectionOutOfRange(leftTop: CellAddress, rightBottom: CellAddress) {
+ const safeBounds = getSafeAddressBounds({ leftTop, rightBottom });
+
+ const _cells = rangedCells.value;
+ for (const cell of _cells) {
+ const outOfRangeCol = cell.address.col < safeBounds.leftTop.col || cell.address.col > safeBounds.rightBottom.col;
+ const outOfRangeRow = cell.address.row < safeBounds.leftTop.row || cell.address.row > safeBounds.rightBottom.row;
+ if (outOfRangeCol || outOfRangeRow) {
+ cell.ranged = false;
+ }
+ }
+
+ const outOfRangeRows = rows.value.filter((_, index) => index < safeBounds.leftTop.row || index > safeBounds.rightBottom.row);
+ for (const row of outOfRangeRows) {
+ row.ranged = false;
+ }
+}
+
+/**
+ * {@link leftTop}から{@link rightBottom}の範囲内にあるセルを範囲選択状態にする。
+ */
+function expandCellRange(leftTop: CellAddress, rightBottom: CellAddress) {
+ const safeBounds = getSafeAddressBounds({ leftTop, rightBottom });
+ const targetRows = cells.value.slice(safeBounds.leftTop.row, safeBounds.rightBottom.row + 1);
+ for (const row of targetRows) {
+ for (const cell of row.cells.slice(safeBounds.leftTop.col, safeBounds.rightBottom.col + 1)) {
+ cell.ranged = true;
+ }
+ }
+}
+
+/**
+ * {@link top}から{@link bottom}までの行を範囲選択状態にする。
+ */
+function expandRowRange(top: number, bottom: number) {
+ if (!rowSetting.selectable) {
+ return;
+ }
+
+ const targetRows = rows.value.slice(top, bottom + 1);
+ for (const row of targetRows) {
+ row.ranged = true;
+ }
+}
+
+/**
+ * 特定の条件下でのみ適用されるCSSを反映する。
+ */
+function applyRowRules(targetCells: GridCell[]) {
+ const _rows = rows.value;
+ const targetRowIdxes = [...new Set(targetCells.map(it => it.address.row))];
+ const rowGroups = Array.of<{ row: GridRow, cells: GridCell[] }>();
+ for (const rowIdx of targetRowIdxes) {
+ const rowGroup = targetCells.filter(it => it.address.row === rowIdx);
+ rowGroups.push({ row: _rows[rowIdx], cells: rowGroup });
+ }
+
+ const _cells = cells.value;
+ for (const group of rowGroups.filter(it => it.row.using)) {
+ const row = group.row;
+ const targetCols = group.cells.map(it => it.column);
+ const rowCells = _cells[group.row.index].cells;
+
+ const newStyles = rowSetting.styleRules
+ .filter(it => it.condition({ row, targetCols, cells: rowCells }))
+ .map(it => it.applyStyle);
+
+ if (JSON.stringify(newStyles) !== JSON.stringify(row.additionalStyles)) {
+ row.additionalStyles = newStyles;
+ }
+ }
+}
+
+function availableCellAddress(cellAddress: CellAddress): boolean {
+ const safeBounds = availableBounds.value;
+ return cellAddress.row >= safeBounds.leftTop.row &&
+ cellAddress.col >= safeBounds.leftTop.col &&
+ cellAddress.row <= safeBounds.rightBottom.row &&
+ cellAddress.col <= safeBounds.rightBottom.col;
+}
+
+function isColumnHeaderCellAddress(cellAddress: CellAddress): boolean {
+ return cellAddress.row === -1 && cellAddress.col >= 0;
+}
+
+function isRowNumberCellAddress(cellAddress: CellAddress): boolean {
+ return cellAddress.row >= 0 && cellAddress.col === -1;
+}
+
+function getSafeAddressBounds(
+ bounds: { leftTop: CellAddress, rightBottom: CellAddress },
+): { leftTop: CellAddress, rightBottom: CellAddress } {
+ const available = availableBounds.value;
+
+ const safeLeftTop = {
+ col: Math.max(bounds.leftTop.col, available.leftTop.col),
+ row: Math.max(bounds.leftTop.row, available.leftTop.row),
+ };
+ const safeRightBottom = {
+ col: Math.min(bounds.rightBottom.col, available.rightBottom.col),
+ row: Math.min(bounds.rightBottom.row, available.rightBottom.row),
+ };
+
+ return { leftTop: safeLeftTop, rightBottom: safeRightBottom };
+}
+
+function registerMouseMove() {
+ unregisterMouseMove();
+ addEventListener('mousemove', onMouseMove);
+}
+
+function unregisterMouseMove() {
+ removeEventListener('mousemove', onMouseMove);
+}
+
+function registerMouseUp() {
+ unregisterMouseUp();
+ addEventListener('mouseup', onMouseUp);
+}
+
+function unregisterMouseUp() {
+ removeEventListener('mouseup', onMouseUp);
+}
+
+function createContext(): GridContext {
+ return {
+ selectedCell: selectedCell.value,
+ rangedCells: rangedCells.value,
+ rangedRows: rangedRows.value,
+ randedBounds: rangedBounds.value,
+ availableBounds: availableBounds.value,
+ state: state.value,
+ rows: rows.value,
+ columns: columns.value,
+ };
+}
+
+function refreshData() {
+ if (_DEV_) {
+ console.log('[grid][refresh-data][begin]');
+ }
+
+ // データを元に行・列・セルを作成する。
+ // 行は元データの配列の長さに応じて作成するが、最低限の行数は設定によって決まる。
+ // 行数が変わるたびに都度レンダリングするとパフォーマンスがイマイチなので、あらかじめ多めにセルを用意しておくための措置。
+ const _data: DataSource[] = data.value;
+ const _rows: GridRow[] = (_data.length > rowSetting.minimumDefinitionCount)
+ ? _data.map((_, index) => createRow(index, true, rowSetting))
+ : Array.from({ length: rowSetting.minimumDefinitionCount }, (_, index) => createRow(index, index < _data.length, rowSetting));
+ const _cols: GridColumn[] = columns.value;
+
+ // 行・列の定義から、元データの配列より値を取得してセルを作成する。
+ // 行・列の定義はそれぞれインデックスを持っており、そのインデックスは元データの配列番地に対応している。
+ const _cells: RowHolder[] = _rows.map(row => {
+ const newCells = row.using
+ ? _cols.map(col => createCell(col, row, _data[row.index][col.setting.bindTo], cellSettings))
+ : _cols.map(col => createCell(col, row, undefined, cellSettings));
+
+ return { row, cells: newCells, origin: _data[row.index] };
+ });
+
+ rows.value = _rows;
+ cells.value = _cells;
+
+ const allCells = _cells.filter(it => it.row.using).flatMap(it => it.cells);
+ for (const cell of allCells) {
+ cell.violation = cellValidation(allCells, cell, cell.value);
+ }
+
+ applyRowRules(allCells);
+
+ if (_DEV_) {
+ console.log('[grid][refresh-data][end]');
+ }
+}
+
+/**
+ * セル値を部分更新する。この関数は、外部起因でデータが変更された場合に呼ばれる。
+ *
+ * 外部起因でデータが変更された場合は{@link data}の値が変更されるが、何処の番地がどのように変わったのかまでは検知できない。
+ * セルをすべて作り直せばいいが、その手法だと以下のデメリットがある。
+ * - 描画負荷がかかる
+ * - 各セルが持つ個別の状態(選択中状態やバリデーション結果など)が失われる
+ *
+ * そこで、新しい値とセルが持つ値を突き合わせ、変更があった場合のみ値を更新し、セルそのものは使いまわしつつ値を最新化する。
+ */
+function patchData(newItems: DataSource[]) {
+ if (_DEV_) {
+ console.log('[grid][patch-data][begin]');
+ }
+
+ const _cols = columns.value;
+
+ if (rows.value.length < newItems.length) {
+ const newRows = Array.of<GridRow>();
+ const newCells = Array.of<RowHolder>();
+
+ // 未使用の行を含めても足りないので新しい行を追加する
+ for (let rowIdx = rows.value.length; rowIdx < newItems.length; rowIdx++) {
+ const newRow = createRow(rowIdx, true, rowSetting);
+ newRows.push(newRow);
+ newCells.push({
+ row: newRow,
+ cells: _cols.map(col => createCell(col, newRow, newItems[rowIdx][col.setting.bindTo], cellSettings)),
+ origin: newItems[rowIdx],
+ });
+ }
+
+ rows.value.push(...newRows);
+ cells.value.push(...newCells);
+
+ applyRowRules(newCells.flatMap(it => it.cells));
+ }
+
+ // 行数の上限が欲しい場合はここに設けてもいいかもしれない
+
+ const usingRows = rows.value.filter(it => it.using);
+ if (usingRows.length > newItems.length) {
+ // 行数が減っているので古い行をクリアする(再マウント・再レンダリングが重いので要素そのものは消さない)
+ for (let rowIdx = newItems.length; rowIdx < usingRows.length; rowIdx++) {
+ resetRow(rows.value[rowIdx]);
+ for (let colIdx = 0; colIdx < _cols.length; colIdx++) {
+ const holder = cells.value[rowIdx];
+ holder.origin = {};
+ resetCell(holder.cells[colIdx]);
+ }
+ }
+ }
+
+ // 新しい値と既に設定されていた値を入れ替える
+ const changedCells = Array.of<GridCell>();
+ for (let rowIdx = 0; rowIdx < newItems.length; rowIdx++) {
+ const holder = cells.value[rowIdx];
+ holder.row.using = true;
+
+ const oldCells = holder.cells;
+ const newItem = newItems[rowIdx];
+ for (let colIdx = 0; colIdx < oldCells.length; colIdx++) {
+ const _col = columns.value[colIdx];
+
+ const oldCell = oldCells[colIdx];
+ const newValue = newItem[_col.setting.bindTo];
+ if (oldCell.value !== newValue) {
+ oldCell.value = _col.setting.valueTransformer
+ ? _col.setting.valueTransformer(holder.row, _col, newValue)
+ : newValue;
+ changedCells.push(oldCell);
+ }
+ }
+ }
+
+ if (changedCells.length > 0) {
+ const allCells = cells.value.slice(0, newItems.length).flatMap(it => it.cells);
+ for (const cell of allCells) {
+ cell.violation = cellValidation(allCells, cell, cell.value);
+ }
+
+ applyRowRules(changedCells);
+
+ // セル値が書き換わっており、バリデーションの結果も変わっているので外部に通知する必要がある
+ emitGridEvent({
+ type: 'cell-validation',
+ all: cells.value
+ .filter(it => it.row.using)
+ .flatMap(it => it.cells)
+ .map(it => it.violation)
+ .filter(it => !it.valid),
+ });
+ }
+
+ if (_DEV_) {
+ console.log('[grid][patch-data][end]');
+ }
+}
+
+// endregion
+// #endregion
+
+onMounted(() => {
+ state.value = 'normal';
+
+ const bindToList = columnSettings.map(it => it.bindTo);
+ if (new Set(bindToList).size !== columnSettings.length) {
+ // 取得元のプロパティ名重複は許容したくない
+ throw new Error(`Duplicate bindTo setting : [${bindToList.join(',')}]}]`);
+ }
+
+ if (rootEl.value) {
+ resizeObserver.observe(rootEl.value);
+
+ // 初期表示時にコンテンツが表示されていない場合はhidden状態にしておく。
+ // コンテンツ表示時にresizeイベントが発生するが、そのときにhidden状態にしておかないとサイズの再計算が走らないので
+ const bounds = rootEl.value.getBoundingClientRect();
+ if (bounds.width === 0 || bounds.height === 0) {
+ state.value = 'hidden';
+ }
+ }
+
+ refreshData();
+});
+</script>
+
+<style module lang="scss">
+.grid {
+ font-size: 90%;
+ overflow-x: scroll;
+ // firefoxだとスクロールバーがセルに重なって見づらくなってしまうのでスペースを空けておく
+ padding-bottom: 8px;
+
+ &.noOverflowHandling {
+ overflow-x: revert;
+ padding-bottom: 0;
+ }
+}
+</style>
+
+<style lang="scss">
+$borderSetting: solid 0.5px var(--MI_THEME-divider);
+
+// 配下コンポーネントを含めて一括してコントロールするため、scopedもmoduleも使用できない
+.mk_grid_border {
+ --rootBorderSetting: none;
+ --borderRadius: 0;
+
+ border-spacing: 0;
+
+ &.mk_grid_root_border {
+ --rootBorderSetting: #{$borderSetting};
+ }
+
+ &.mk_grid_root_rounded {
+ --borderRadius: var(--MI-radius);
+ }
+
+ .mk_grid_thead {
+ .mk_grid_tr {
+ .mk_grid_th {
+ border-left: $borderSetting;
+ border-top: var(--rootBorderSetting);
+
+ &:first-child {
+ // 左上セル
+ border-left: var(--rootBorderSetting);
+ border-top-left-radius: var(--borderRadius);
+ }
+
+ &:last-child {
+ // 右上セル
+ border-top-right-radius: var(--borderRadius);
+ border-right: var(--rootBorderSetting);
+ }
+ }
+ }
+ }
+
+ .mk_grid_tbody {
+ .mk_grid_tr {
+ .mk_grid_td, .mk_grid_th {
+ border-left: $borderSetting;
+ border-top: $borderSetting;
+
+ &:first-child {
+ // 左端の列
+ border-left: var(--rootBorderSetting);
+ }
+
+ &:last-child {
+ // 一番右端の列
+ border-right: var(--rootBorderSetting);
+ }
+ }
+ }
+
+ .last_row {
+ .mk_grid_td, .mk_grid_th {
+ // 一番下の行
+ border-bottom: var(--rootBorderSetting);
+
+ &:first-child {
+ // 左下セル
+ border-bottom-left-radius: var(--borderRadius);
+ }
+
+ &:last-child {
+ // 右下セル
+ border-bottom-right-radius: var(--borderRadius);
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue
new file mode 100644
index 0000000000..aecfe7eaa3
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkHeaderCell.vue
@@ -0,0 +1,216 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ class="mk_grid_th"
+ :class="$style.cell"
+ :style="[{ maxWidth: column.width, minWidth: column.width, width: column.width }]"
+ data-grid-cell
+ :data-grid-cell-row="-1"
+ :data-grid-cell-col="column.index"
+>
+ <div :class="$style.root">
+ <div :class="$style.left"></div>
+ <div :class="$style.wrapper">
+ <div ref="contentEl" :class="$style.contentArea">
+ <span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"></span>
+ <span v-else>{{ text }}</span>
+ </div>
+ </div>
+ <div
+ :class="$style.right"
+ @mousedown="onHandleMouseDown"
+ @dblclick="onHandleDoubleClick"
+ ></div>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue';
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import { GridColumn } from '@/components/grid/column.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginWidthChange', sender: GridColumn): void;
+ (ev: 'operation:endWidthChange', sender: GridColumn): void;
+ (ev: 'operation:widthLargest', sender: GridColumn): void;
+ (ev: 'change:width', sender: GridColumn, width: string): void;
+ (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
+}>();
+const props = defineProps<{
+ column: GridColumn,
+ bus: GridEventEmitter,
+}>();
+
+const { column, bus } = toRefs(props);
+
+const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
+const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
+
+const resizing = ref<boolean>(false);
+
+const text = computed(() => {
+ const result = column.value.setting.title ?? column.value.setting.bindTo;
+ return result.length > 0 ? result : ' ';
+});
+
+watch(column, () => {
+ // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
+ nextTick(emitContentSizeChanged);
+}, { immediate: true });
+
+function onHandleDoubleClick(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'dblclick': {
+ emit('operation:widthLargest', column.value);
+ break;
+ }
+ }
+}
+
+function onHandleMouseDown(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'mousedown': {
+ if (!resizing.value) {
+ registerHandleMouseUp();
+ registerHandleMouseMove();
+ resizing.value = true;
+ emit('operation:beginWidthChange', column.value);
+ }
+ break;
+ }
+ }
+}
+
+function onHandleMouseMove(ev: MouseEvent) {
+ if (!rootEl.value) {
+ // 型ガード
+ return;
+ }
+
+ switch (ev.type) {
+ case 'mousemove': {
+ if (resizing.value) {
+ const bounds = rootEl.value.getBoundingClientRect();
+ const clientWidth = rootEl.value.clientWidth;
+ const clientRight = bounds.left + clientWidth;
+ const nextWidth = clientWidth + (ev.clientX - clientRight);
+ emit('change:width', column.value, `${nextWidth}px`);
+ }
+ break;
+ }
+ }
+}
+
+function onHandleMouseUp(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'mouseup': {
+ if (resizing.value) {
+ unregisterHandleMouseUp();
+ unregisterHandleMouseMove();
+ resizing.value = false;
+ emit('operation:endWidthChange', column.value);
+ }
+ break;
+ }
+ }
+}
+
+function onForceRefreshContentSize() {
+ emitContentSizeChanged();
+}
+
+function registerHandleMouseMove() {
+ unregisterHandleMouseMove();
+ addEventListener('mousemove', onHandleMouseMove);
+}
+
+function unregisterHandleMouseMove() {
+ removeEventListener('mousemove', onHandleMouseMove);
+}
+
+function registerHandleMouseUp() {
+ unregisterHandleMouseUp();
+ addEventListener('mouseup', onHandleMouseUp);
+}
+
+function unregisterHandleMouseUp() {
+ removeEventListener('mouseup', onHandleMouseUp);
+}
+
+function emitContentSizeChanged() {
+ const clientWidth = contentEl.value?.clientWidth ?? 0;
+ const clientHeight = contentEl.value?.clientHeight ?? 0;
+ emit('change:contentSize', column.value, {
+ // バーの横幅も考慮したいので、+3px
+ width: clientWidth + 3 + 3,
+ height: clientHeight,
+ });
+}
+
+onMounted(() => {
+ bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+onUnmounted(() => {
+ bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+</script>
+
+<style module lang="scss">
+$handleWidth: 5px;
+$cellHeight: 28px;
+
+.cell {
+ cursor: pointer;
+}
+
+.root {
+ display: flex;
+ flex-direction: row;
+ height: $cellHeight;
+ max-height: $cellHeight;
+ min-height: $cellHeight;
+
+ .wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ justify-content: center;
+ }
+
+ .contentArea {
+ display: flex;
+ padding: 6px 4px;
+ box-sizing: border-box;
+ overflow: hidden;
+ white-space: nowrap;
+ text-align: center;
+ }
+
+ .left {
+ // rightのぶんだけズレるのでそれを相殺するためのネガティブマージン
+ margin-left: -$handleWidth;
+ margin-right: auto;
+ width: $handleWidth;
+ min-width: $handleWidth;
+ }
+
+ .right {
+ margin-left: auto;
+ // 判定を罫線の上に重ねたいのでネガティブマージンを使う
+ margin-right: -$handleWidth;
+ width: $handleWidth;
+ min-width: $handleWidth;
+ cursor: w-resize;
+ z-index: 1;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue
new file mode 100644
index 0000000000..8affa08fd5
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkHeaderRow.vue
@@ -0,0 +1,60 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="mk_grid_tr"
+ :class="$style.root"
+ :data-grid-row="-1"
+>
+ <MkNumberCell
+ v-if="gridSetting.showNumber"
+ content="#"
+ :top="true"
+ />
+ <MkHeaderCell
+ v-for="column in columns"
+ :key="column.index"
+ :column="column"
+ :bus="bus"
+ @operation:beginWidthChange="(sender) => emit('operation:beginWidthChange', sender)"
+ @operation:endWidthChange="(sender) => emit('operation:endWidthChange', sender)"
+ @operation:widthLargest="(sender) => emit('operation:widthLargest', sender)"
+ @change:width="(sender, width) => emit('change:width', sender, width)"
+ @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
+ />
+</div>
+</template>
+
+<script setup lang="ts">
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import MkHeaderCell from '@/components/grid/MkHeaderCell.vue';
+import MkNumberCell from '@/components/grid/MkNumberCell.vue';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRowSetting } from '@/components/grid/row.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginWidthChange', sender: GridColumn): void;
+ (ev: 'operation:endWidthChange', sender: GridColumn): void;
+ (ev: 'operation:widthLargest', sender: GridColumn): void;
+ (ev: 'operation:selectionColumn', sender: GridColumn): void;
+ (ev: 'change:width', sender: GridColumn, width: string): void;
+ (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
+}>();
+
+defineProps<{
+ columns: GridColumn[],
+ gridSetting: GridRowSetting,
+ bus: GridEventEmitter,
+}>();
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue
new file mode 100644
index 0000000000..674bba96bc
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkNumberCell.vue
@@ -0,0 +1,61 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="mk_grid_th"
+ :class="[$style.cell]"
+ :tabindex="-1"
+ data-grid-cell
+ :data-grid-cell-row="row?.index ?? -1"
+ :data-grid-cell-col="-1"
+>
+ <div :class="[$style.root]">
+ {{ content }}
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+
+import { GridRow } from '@/components/grid/row.js';
+
+defineProps<{
+ content: string,
+ row?: GridRow,
+}>();
+
+</script>
+
+<style module lang="scss">
+$cellHeight: 28px;
+$cellWidth: 34px;
+
+.cell {
+ overflow: hidden;
+ white-space: nowrap;
+ height: $cellHeight;
+ max-height: $cellHeight;
+ min-height: $cellHeight;
+ min-width: $cellWidth;
+ width: $cellWidth;
+ cursor: pointer;
+}
+
+.root {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ box-sizing: border-box;
+ padding: 0 8px;
+ height: 100%;
+ border: solid 0.5px transparent;
+
+ &.selected {
+ background-color: var(--MI_THEME-accentedBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts
new file mode 100644
index 0000000000..949cab2ec6
--- /dev/null
+++ b/packages/frontend/src/components/grid/cell-validators.ts
@@ -0,0 +1,110 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRow } from '@/components/grid/row.js';
+import { i18n } from '@/i18n.js';
+
+export type ValidatorParams = {
+ column: GridColumn;
+ row: GridRow;
+ value: CellValue;
+ allCells: GridCell[];
+};
+
+export type ValidatorResult = {
+ valid: boolean;
+ message?: string;
+}
+
+export type GridCellValidator = {
+ name?: string;
+ ignoreViolation?: boolean;
+ validate: (params: ValidatorParams) => ValidatorResult;
+}
+
+export type ValidateViolation = {
+ valid: boolean;
+ params: ValidatorParams;
+ violations: ValidateViolationItem[];
+}
+
+export type ValidateViolationItem = {
+ valid: boolean;
+ validator: GridCellValidator;
+ result: ValidatorResult;
+}
+
+export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation {
+ const { column, row } = cell;
+ const validators = column.setting.validators ?? [];
+
+ const params: ValidatorParams = {
+ column,
+ row,
+ value: newValue,
+ allCells,
+ };
+
+ const violations: ValidateViolationItem[] = validators.map(validator => {
+ const result = validator.validate(params);
+ return {
+ valid: result.valid,
+ validator,
+ result,
+ };
+ });
+
+ return {
+ valid: violations.every(v => v.result.valid),
+ params,
+ violations,
+ };
+}
+
+class ValidatorPreset {
+ required(): GridCellValidator {
+ return {
+ name: 'required',
+ validate: ({ value }): ValidatorResult => {
+ return {
+ valid: value !== null && value !== undefined && value !== '',
+ message: i18n.ts._gridComponent._error.requiredValue,
+ };
+ },
+ };
+ }
+
+ regex(pattern: RegExp): GridCellValidator {
+ return {
+ name: 'regex',
+ validate: ({ value }): ValidatorResult => {
+ return {
+ valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''),
+ message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }),
+ };
+ },
+ };
+ }
+
+ unique(): GridCellValidator {
+ return {
+ name: 'unique',
+ validate: ({ column, row, value, allCells }): ValidatorResult => {
+ const bindTo = column.setting.bindTo;
+ const isUnique = allCells
+ .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index)
+ .every(cell => cell.value !== value);
+ return {
+ valid: isUnique,
+ message: i18n.ts._gridComponent._error.notUnique,
+ };
+ },
+ };
+ }
+}
+
+export const validators = new ValidatorPreset();
diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts
new file mode 100644
index 0000000000..71b7a3e3f1
--- /dev/null
+++ b/packages/frontend/src/components/grid/cell.ts
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ValidateViolation } from '@/components/grid/cell-validators.js';
+import { Size } from '@/components/grid/grid.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRow } from '@/components/grid/row.js';
+import { MenuItem } from '@/types/menu.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+
+export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>;
+
+export type CellAddress = {
+ row: number;
+ col: number;
+}
+
+export const CELL_ADDRESS_NONE: CellAddress = {
+ row: -1,
+ col: -1,
+};
+
+export type GridCell = {
+ address: CellAddress;
+ value: CellValue;
+ column: GridColumn;
+ row: GridRow;
+ selected: boolean;
+ ranged: boolean;
+ contentSize: Size;
+ setting: GridCellSetting;
+ violation: ValidateViolation;
+}
+
+export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[];
+
+export type GridCellSetting = {
+ contextMenuFactory?: GridCellContextMenuFactory;
+}
+
+export function createCell(
+ column: GridColumn,
+ row: GridRow,
+ value: CellValue,
+ setting: GridCellSetting,
+): GridCell {
+ const newValue = (row.using && column.setting.valueTransformer)
+ ? column.setting.valueTransformer(row, column, value)
+ : value;
+
+ return {
+ address: { row: row.index, col: column.index },
+ value: newValue,
+ column,
+ row,
+ selected: false,
+ ranged: false,
+ contentSize: { width: 0, height: 0 },
+ violation: {
+ valid: true,
+ params: {
+ column,
+ row,
+ value,
+ allCells: [],
+ },
+ violations: [],
+ },
+ setting,
+ };
+}
+
+export function resetCell(cell: GridCell): void {
+ cell.selected = false;
+ cell.ranged = false;
+ cell.violation = {
+ valid: true,
+ params: {
+ column: cell.column,
+ row: cell.row,
+ value: cell.value,
+ allCells: [],
+ },
+ violations: [],
+ };
+}
diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts
new file mode 100644
index 0000000000..2f505756fe
--- /dev/null
+++ b/packages/frontend/src/components/grid/column.ts
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { GridCellValidator } from '@/components/grid/cell-validators.js';
+import { Size, SizeStyle } from '@/components/grid/grid.js';
+import { calcCellWidth } from '@/components/grid/grid-utils.js';
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridRow } from '@/components/grid/row.js';
+import { MenuItem } from '@/types/menu.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+
+export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden';
+
+export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise<CellValue>;
+export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue;
+export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[];
+
+export type GridColumnSetting = {
+ bindTo: string;
+ title?: string;
+ icon?: string;
+ type: ColumnType;
+ width: SizeStyle;
+ editable?: boolean;
+ validators?: GridCellValidator[];
+ customValueEditor?: CustomValueEditor;
+ valueTransformer?: CellValueTransformer;
+ contextMenuFactory?: GridColumnContextMenuFactory;
+ events?: {
+ copy?: (value: CellValue) => string;
+ paste?: (text: string) => CellValue;
+ delete?: (cell: GridCell, context: GridContext) => void;
+ }
+};
+
+export type GridColumn = {
+ index: number;
+ setting: GridColumnSetting;
+ width: string;
+ contentSize: Size;
+}
+
+export function createColumn(setting: GridColumnSetting, index: number): GridColumn {
+ return {
+ index,
+ setting,
+ width: calcCellWidth(setting.width),
+ contentSize: { width: 0, height: 0 },
+ };
+}
+
diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts
new file mode 100644
index 0000000000..074b72b956
--- /dev/null
+++ b/packages/frontend/src/components/grid/grid-event.ts
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridState } from '@/components/grid/grid.js';
+import { ValidateViolation } from '@/components/grid/cell-validators.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRow } from '@/components/grid/row.js';
+
+export type GridContext = {
+ selectedCell?: GridCell;
+ rangedCells: GridCell[];
+ rangedRows: GridRow[];
+ randedBounds: {
+ leftTop: CellAddress;
+ rightBottom: CellAddress;
+ };
+ availableBounds: {
+ leftTop: CellAddress;
+ rightBottom: CellAddress;
+ };
+ state: GridState;
+ rows: GridRow[];
+ columns: GridColumn[];
+};
+
+export type GridEvent =
+ GridCellValueChangeEvent |
+ GridCellValidationEvent
+ ;
+
+export type GridCellValueChangeEvent = {
+ type: 'cell-value-change';
+ column: GridColumn;
+ row: GridRow;
+ oldValue: CellValue;
+ newValue: CellValue;
+};
+
+export type GridCellValidationEvent = {
+ type: 'cell-validation';
+ violation?: ValidateViolation;
+ all: ValidateViolation[];
+};
diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts
new file mode 100644
index 0000000000..a45bc88926
--- /dev/null
+++ b/packages/frontend/src/components/grid/grid-utils.ts
@@ -0,0 +1,215 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { isRef, Ref } from 'vue';
+import { DataSource, SizeStyle } from '@/components/grid/grid.js';
+import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridRow } from '@/components/grid/row.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { GridColumn, GridColumnSetting } from '@/components/grid/column.js';
+
+export function isCellElement(elem: HTMLElement): boolean {
+ return elem.hasAttribute('data-grid-cell');
+}
+
+export function isRowElement(elem: HTMLElement): boolean {
+ return elem.hasAttribute('data-grid-row');
+}
+
+export function calcCellWidth(widthSetting: SizeStyle): string {
+ switch (widthSetting) {
+ case undefined:
+ case 'auto': {
+ return 'auto';
+ }
+ default: {
+ return `${widthSetting}px`;
+ }
+ }
+}
+
+function getCellRowByAttribute(elem: HTMLElement): number {
+ const row = elem.getAttribute('data-grid-cell-row');
+ if (row === null) {
+ throw new Error('data-grid-cell-row attribute not found');
+ }
+ return Number(row);
+}
+
+function getCellColByAttribute(elem: HTMLElement): number {
+ const col = elem.getAttribute('data-grid-cell-col');
+ if (col === null) {
+ throw new Error('data-grid-cell-col attribute not found');
+ }
+ return Number(col);
+}
+
+export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
+ let node = elem;
+ for (let i = 0; i < parentNodeCount; i++) {
+ if (!node.parentElement) {
+ break;
+ }
+
+ if (isCellElement(node) && isRowElement(node.parentElement)) {
+ const row = getCellRowByAttribute(node);
+ const col = getCellColByAttribute(node);
+
+ return { row, col };
+ }
+
+ node = node.parentElement;
+ }
+
+ return CELL_ADDRESS_NONE;
+}
+
+export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null {
+ let node = elem;
+ for (let i = 0; i < parentNodeCount; i++) {
+ if (isCellElement(node)) {
+ return node;
+ }
+
+ if (!node.parentElement) {
+ break;
+ }
+
+ node = node.parentElement;
+ }
+
+ return null;
+}
+
+export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
+ return a.row === b.row && a.col === b.col;
+}
+
+/**
+ * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。
+ */
+export function copyGridDataToClipboard(
+ gridItems: Ref<DataSource[]> | DataSource[],
+ context: GridContext,
+) {
+ const items = isRef(gridItems) ? gridItems.value : gridItems;
+ const lines = Array.of<string>();
+ const bounds = context.randedBounds;
+
+ for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
+ const rowItems = Array.of<string>();
+ for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
+ const { bindTo, events } = context.columns[col].setting;
+ const value = items[row][bindTo];
+ const transformValue = events?.copy
+ ? events.copy(value)
+ : typeof value === 'object' || Array.isArray(value)
+ ? JSON.stringify(value)
+ : value?.toString() ?? '';
+ rowItems.push(transformValue);
+ }
+ lines.push(rowItems.join('\t'));
+ }
+
+ const text = lines.join('\n');
+ copyToClipboard(text);
+
+ if (_DEV_) {
+ console.log(`Copied to clipboard: ${text}`);
+ }
+}
+
+/**
+ * クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。
+ * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
+ */
+export async function pasteToGridFromClipboard(
+ context: GridContext,
+ callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void,
+) {
+ function parseValue(value: string, setting: GridColumnSetting): CellValue {
+ if (setting.events?.paste) {
+ return setting.events.paste(value);
+ } else {
+ switch (setting.type) {
+ case 'number': {
+ return Number(value);
+ }
+ case 'boolean': {
+ return value === 'true';
+ }
+ default: {
+ return value;
+ }
+ }
+ }
+ }
+
+ const clipBoardText = await navigator.clipboard.readText();
+ if (_DEV_) {
+ console.log(`Paste from clipboard: ${clipBoardText}`);
+ }
+
+ const bounds = context.randedBounds;
+ const lines = clipBoardText.replace(/\r/g, '')
+ .split('\n')
+ .map(it => it.split('\t'));
+
+ if (lines.length === 1 && lines[0].length === 1) {
+ // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
+ const ranges = context.rangedCells;
+ for (const cell of ranges) {
+ if (cell.column.setting.editable) {
+ callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting));
+ }
+ }
+ } else {
+ // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
+ const offsetRow = bounds.leftTop.row;
+ const offsetCol = bounds.leftTop.col;
+ const { columns, rows } = context;
+ for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
+ const rowIdx = row - offsetRow;
+ if (lines.length <= rowIdx) {
+ // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
+ break;
+ }
+
+ const items = lines[rowIdx];
+ for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
+ const colIdx = col - offsetCol;
+ if (items.length <= colIdx) {
+ // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
+ break;
+ }
+
+ if (columns[col].setting.editable) {
+ callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。
+ * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
+ */
+export function removeDataFromGrid(
+ context: GridContext,
+ callback: (cell: GridCell) => void,
+) {
+ for (const cell of context.rangedCells) {
+ const { editable, events } = cell.column.setting;
+ if (editable) {
+ if (events?.delete) {
+ events.delete(cell, context);
+ } else {
+ callback(cell);
+ }
+ }
+ }
+}
diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts
new file mode 100644
index 0000000000..b82e12b304
--- /dev/null
+++ b/packages/frontend/src/components/grid/grid.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import { CellValue, GridCellSetting } from '@/components/grid/cell.js';
+import { GridColumnSetting } from '@/components/grid/column.js';
+import { GridRowSetting } from '@/components/grid/row.js';
+
+export type GridSetting = {
+ root?: {
+ noOverflowStyle?: boolean;
+ rounded?: boolean;
+ outerBorder?: boolean;
+ };
+ row?: GridRowSetting;
+ cols: GridColumnSetting[];
+ cells?: GridCellSetting;
+};
+
+export type DataSource = Record<string, CellValue>;
+
+export type GridState =
+ 'normal' |
+ 'cellSelecting' |
+ 'cellEditing' |
+ 'colResizing' |
+ 'colSelecting' |
+ 'rowSelecting' |
+ 'hidden'
+ ;
+
+export type Size = {
+ width: number;
+ height: number;
+}
+
+export type SizeStyle = number | 'auto' | undefined;
+
+export type AdditionalStyle = {
+ className?: string;
+ style?: Record<string, string | number>;
+}
+
+export class GridEventEmitter extends EventEmitter<{
+ 'forceRefreshContentSize': void;
+}> {
+}
diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts
new file mode 100644
index 0000000000..e0a317c9d3
--- /dev/null
+++ b/packages/frontend/src/components/grid/row.ts
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { AdditionalStyle } from '@/components/grid/grid.js';
+import { GridCell } from '@/components/grid/cell.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { MenuItem } from '@/types/menu.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+
+export const defaultGridRowSetting: Required<GridRowSetting> = {
+ showNumber: true,
+ selectable: true,
+ minimumDefinitionCount: 100,
+ styleRules: [],
+ contextMenuFactory: () => [],
+ events: {},
+};
+
+export type GridRowStyleRuleConditionParams = {
+ row: GridRow,
+ targetCols: GridColumn[],
+ cells: GridCell[]
+};
+
+export type GridRowStyleRule = {
+ condition: (params: GridRowStyleRuleConditionParams) => boolean;
+ applyStyle: AdditionalStyle;
+}
+
+export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[];
+
+export type GridRowSetting = {
+ showNumber?: boolean;
+ selectable?: boolean;
+ minimumDefinitionCount?: number;
+ styleRules?: GridRowStyleRule[];
+ contextMenuFactory?: GridRowContextMenuFactory;
+ events?: {
+ delete?: (rows: GridRow[]) => void;
+ }
+}
+
+export type GridRow = {
+ index: number;
+ ranged: boolean;
+ using: boolean;
+ setting: GridRowSetting;
+ additionalStyles: AdditionalStyle[];
+}
+
+export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow {
+ return {
+ index,
+ ranged: false,
+ using: using,
+ setting,
+ additionalStyles: [],
+ };
+}
+
+export function resetRow(row: GridRow): void {
+ row.ranged = false;
+ row.using = false;
+ row.additionalStyles = [];
+}
+
diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/components/hook/useLoading.ts
new file mode 100644
index 0000000000..6c6ff6ae0d
--- /dev/null
+++ b/packages/frontend/src/components/hook/useLoading.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, h, ref } from 'vue';
+import MkLoading from '@/components/global/MkLoading.vue';
+
+export const useLoading = (props?: {
+ static?: boolean;
+ inline?: boolean;
+ colored?: boolean;
+ mini?: boolean;
+ em?: boolean;
+}) => {
+ const showingCnt = ref(0);
+
+ const show = () => {
+ showingCnt.value++;
+ };
+
+ const close = (force?: boolean) => {
+ if (force) {
+ showingCnt.value = 0;
+ } else {
+ showingCnt.value = Math.max(0, showingCnt.value - 1);
+ }
+ };
+
+ const scope = <T>(fn: () => T) => {
+ show();
+
+ const result = fn();
+ if (result instanceof Promise) {
+ return result.finally(() => close());
+ } else {
+ close();
+ return result;
+ }
+ };
+
+ const showing = computed(() => showingCnt.value > 0);
+ const component = computed(() => showing.value ? h(MkLoading, props) : null);
+
+ return {
+ show,
+ close,
+ scope,
+ component,
+ showing,
+ };
+};