merge upstream 2025-02-03

This commit is contained in:
Hazelnoot 2025-02-03 14:31:26 -05:00
commit a4e86758c1
264 changed files with 15775 additions and 4919 deletions

View file

@ -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;

View file

@ -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) {
// reCAPTCHAcaptchaEldiv.
// divrenderreCAPTCHA
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({

View file

@ -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;

View file

@ -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;

View file

@ -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';

View file

@ -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>

View file

@ -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>();

View file

@ -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>

View file

@ -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');

View file

@ -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 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 themeColor = instance.themeColor ?? '#777777';
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>

View file

@ -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';

View file

@ -288,20 +288,23 @@ const align = () => {
const onOpened = () => {
emit('opened');
// NOTE: Chromatic undefined
if (content.value == null) return;
// contentnextTick
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 = () => {

View file

@ -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" :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 FunctionLint
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;

View file

@ -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" style="margin-top: 6px;"/>
</div>

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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);
});
@ -624,7 +626,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({
@ -840,7 +842,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,
@ -930,7 +932,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');
}
@ -1140,7 +1142,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 = {

View file

@ -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>

View file

@ -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>

View file

@ -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>;

View file

@ -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="console.log('MkRoleSelectDialog: 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>

View file

@ -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';

View file

@ -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';

View file

@ -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;">

View file

@ -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;
}

View file

@ -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>

View file

@ -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';

View file

@ -47,7 +47,7 @@ export type SuperMenuDef = {
active?: boolean;
action: (ev: MouseEvent) => void;
} | {
type: 'link';
type?: 'link';
to: string;
icon?: string;
text: string;

View file

@ -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>;

View file

@ -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>

View file

@ -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[]>([]);
@ -101,7 +104,7 @@ function search() {
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 +146,7 @@ onMounted(() => {
}).then(foundUsers => {
let _users = foundUsers;
_users = _users.filter((u) => {
if (props.localOnly) {
if (computedLocalOnly.value) {
return u.host == null;
} else {
return true;

View file

@ -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>

View file

@ -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;

View file

@ -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';

View file

@ -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) => {

View file

@ -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';

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;
}
},
},
],
},
},
}));

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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();

View file

@ -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: [],
};
}

View file

@ -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 },
};
}

View file

@ -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[];
};

View file

@ -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);
}
}
}
}

View file

@ -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;
}> {
}

View file

@ -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 = [];
}

View file

@ -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,
};
};