wip
This commit is contained in:
parent
8d1a5734cd
commit
aacee3c970
|
@ -16,7 +16,6 @@ import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { query } from '@/misc/prelude/url.js';
|
|
||||||
import type { Serialized } from '@/types.js';
|
import type { Serialized } from '@/types.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
|
||||||
|
@ -30,10 +29,8 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redis)
|
@Inject(DI.redis)
|
||||||
private redisClient: Redis.Redis,
|
private redisClient: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.emojisRepository)
|
@Inject(DI.emojisRepository)
|
||||||
private emojisRepository: EmojisRepository,
|
private emojisRepository: EmojisRepository,
|
||||||
|
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private emojiEntityService: EmojiEntityService,
|
private emojiEntityService: EmojiEntityService,
|
||||||
|
@ -102,6 +99,64 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||||
return emoji;
|
return emoji;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async addBulk(
|
||||||
|
params: {
|
||||||
|
driveFile: MiDriveFile;
|
||||||
|
name: string;
|
||||||
|
category: string | null;
|
||||||
|
aliases: string[];
|
||||||
|
host: string | null;
|
||||||
|
license: string | null;
|
||||||
|
isSensitive: boolean;
|
||||||
|
localOnly: boolean;
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][];
|
||||||
|
}[],
|
||||||
|
moderator?: MiUser,
|
||||||
|
): Promise<MiEmoji[]> {
|
||||||
|
const emojis = await this.emojisRepository
|
||||||
|
.insert(
|
||||||
|
params.map(it => ({
|
||||||
|
id: this.idService.gen(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: it.name,
|
||||||
|
category: it.category,
|
||||||
|
host: it.host,
|
||||||
|
aliases: it.aliases,
|
||||||
|
originalUrl: it.driveFile.url,
|
||||||
|
publicUrl: it.driveFile.webpublicUrl ?? it.driveFile.url,
|
||||||
|
type: it.driveFile.webpublicType ?? it.driveFile.type,
|
||||||
|
license: it.license,
|
||||||
|
isSensitive: it.isSensitive,
|
||||||
|
localOnly: it.localOnly,
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.then(x => this.emojisRepository.createQueryBuilder('emoji').whereInIds(x.identifiers).getMany());
|
||||||
|
|
||||||
|
const localEmojis = emojis.filter(it => it.host == null);
|
||||||
|
if (localEmojis.length > 0) {
|
||||||
|
this.localEmojisCache.refresh();
|
||||||
|
|
||||||
|
this.emojiEntityService.packDetailedMany(localEmojis).then(it => {
|
||||||
|
for (const emoji of it) {
|
||||||
|
this.globalEventService.publishBroadcastStream('emojiAdded', { emoji });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moderator) {
|
||||||
|
for (const emoji of localEmojis) {
|
||||||
|
this.moderationLogService.log(moderator, 'addCustomEmoji', {
|
||||||
|
emojiId: emoji.id,
|
||||||
|
emoji: emoji,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emojis;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async update(id: MiEmoji['id'], data: {
|
public async update(id: MiEmoji['id'], data: {
|
||||||
driveFile?: MiDriveFile;
|
driveFile?: MiDriveFile;
|
||||||
|
@ -159,6 +214,103 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async updateBulk(
|
||||||
|
params: {
|
||||||
|
id: MiEmoji['id'];
|
||||||
|
driveFile?: MiDriveFile;
|
||||||
|
name?: string;
|
||||||
|
category?: string | null;
|
||||||
|
aliases?: string[];
|
||||||
|
license?: string | null;
|
||||||
|
isSensitive?: boolean;
|
||||||
|
localOnly?: boolean;
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
|
||||||
|
}[],
|
||||||
|
moderator?: MiUser,
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = params.map(it => it.id);
|
||||||
|
|
||||||
|
// IDに対応するものと、新しく設定しようとしている名前と同じ名前を持つレコードをそれぞれ取得する
|
||||||
|
const [storedEmojis, sameNameEmojis] = await Promise.all([
|
||||||
|
this.emojisRepository.createQueryBuilder('emoji')
|
||||||
|
.whereInIds(ids)
|
||||||
|
.getMany()
|
||||||
|
.then(emojis => new Map(emojis.map(it => [it.id, it]))),
|
||||||
|
this.emojisRepository.createQueryBuilder('emoji')
|
||||||
|
.where('emoji.name IN (:...names) AND emoji.host IS NULL', { names: params.map(it => it.name) })
|
||||||
|
.getMany(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 新しく設定しようとしている名前と同じ名前を持つ別レコードがある場合、重複とみなしてエラーとする
|
||||||
|
const alreadyExists = Array.of<string>();
|
||||||
|
for (const sameNameEmoji of sameNameEmojis) {
|
||||||
|
const emoji = storedEmojis.get(sameNameEmoji.id);
|
||||||
|
if (emoji != null && emoji.id !== sameNameEmoji.id) {
|
||||||
|
alreadyExists.push(sameNameEmoji.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alreadyExists.length > 0) {
|
||||||
|
throw new Error(`name already exists: ${alreadyExists.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const emoji of params) {
|
||||||
|
await this.emojisRepository.update(emoji.id, {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
name: emoji.name,
|
||||||
|
category: emoji.category,
|
||||||
|
aliases: emoji.aliases,
|
||||||
|
license: emoji.license,
|
||||||
|
isSensitive: emoji.isSensitive,
|
||||||
|
localOnly: emoji.localOnly,
|
||||||
|
originalUrl: emoji.driveFile != null ? emoji.driveFile.url : undefined,
|
||||||
|
publicUrl: emoji.driveFile != null ? (emoji.driveFile.webpublicUrl ?? emoji.driveFile.url) : undefined,
|
||||||
|
type: emoji.driveFile != null ? (emoji.driveFile.webpublicType ?? emoji.driveFile.type) : undefined,
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.localEmojisCache.refresh();
|
||||||
|
|
||||||
|
// 名前が変わっていないものはそのまま更新としてイベント発信
|
||||||
|
const updateEmojis = params.filter(it => storedEmojis.get(it.id)?.name === it.name);
|
||||||
|
if (updateEmojis.length > 0) {
|
||||||
|
const packedList = await this.emojiEntityService.packDetailedMany(updateEmojis);
|
||||||
|
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||||
|
emojis: packedList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名前が変わったものは削除・追加としてイベント発信
|
||||||
|
const nameChangeEmojis = params.filter(it => storedEmojis.get(it.id)?.name !== it.name);
|
||||||
|
if (nameChangeEmojis.length > 0) {
|
||||||
|
const packedList = await this.emojiEntityService.packDetailedMany(nameChangeEmojis);
|
||||||
|
this.globalEventService.publishBroadcastStream('emojiDeleted', {
|
||||||
|
emojis: packedList,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const packed of packedList) {
|
||||||
|
this.globalEventService.publishBroadcastStream('emojiAdded', {
|
||||||
|
emoji: packed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderator) {
|
||||||
|
const updatedEmojis = await this.emojisRepository.createQueryBuilder('emoji')
|
||||||
|
.whereInIds(storedEmojis.keys())
|
||||||
|
.getMany()
|
||||||
|
.then(it => new Map(it.map(it => [it.id, it])));
|
||||||
|
for (const emoji of storedEmojis.values()) {
|
||||||
|
this.moderationLogService.log(moderator, 'updateCustomEmoji', {
|
||||||
|
emojiId: emoji.id,
|
||||||
|
before: emoji,
|
||||||
|
after: updatedEmojis.get(emoji.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) {
|
public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) {
|
||||||
const emojis = await this.emojisRepository.findBy({
|
const emojis = await this.emojisRepository.findBy({
|
||||||
|
@ -293,7 +445,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
||||||
// クエリに使うホスト
|
// クエリに使うホスト
|
||||||
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
||||||
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
||||||
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
||||||
|
|
|
@ -67,7 +67,7 @@ export class EmojiEntityService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public packDetailedMany(
|
public packDetailedMany(
|
||||||
emojis: any[],
|
emojis: any[],
|
||||||
) {
|
): Promise<Packed<'EmojiDetailed'>[]> {
|
||||||
return Promise.all(emojis.map(x => this.packDetailed(x)));
|
return Promise.all(emojis.map(x => this.packDetailed(x)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,15 +44,21 @@ export const paramDef = {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'Use `null` to reset the category.',
|
description: 'Use `null` to reset the category.',
|
||||||
},
|
},
|
||||||
aliases: { type: 'array', items: {
|
aliases: {
|
||||||
type: 'string',
|
type: 'array',
|
||||||
} },
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
license: { type: 'string', nullable: true },
|
license: { type: 'string', nullable: true },
|
||||||
isSensitive: { type: 'boolean' },
|
isSensitive: { type: 'boolean' },
|
||||||
localOnly: { type: 'boolean' },
|
localOnly: { type: 'boolean' },
|
||||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||||
type: 'string',
|
type: 'array',
|
||||||
} },
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['name', 'fileId'],
|
required: ['name', 'fileId'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -64,9 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
private customEmojiService: CustomEmojiService,
|
private customEmojiService: CustomEmojiService,
|
||||||
|
|
||||||
private emojiEntityService: EmojiEntityService,
|
private emojiEntityService: EmojiEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
|
|
@ -21,42 +21,9 @@ export const meta = {
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
optional: false, nullable: false,
|
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
optional: false, nullable: false,
|
ref: 'EmojiDetailed',
|
||||||
properties: {
|
|
||||||
id: {
|
|
||||||
type: 'string',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
format: 'id',
|
|
||||||
},
|
|
||||||
aliases: {
|
|
||||||
type: 'array',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
items: {
|
|
||||||
type: 'string',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
type: 'string',
|
|
||||||
optional: false, nullable: true,
|
|
||||||
},
|
|
||||||
host: {
|
|
||||||
type: 'string',
|
|
||||||
optional: false, nullable: true,
|
|
||||||
description: 'The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.',
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: 'string',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -88,15 +55,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
let emojis: MiEmoji[];
|
let emojis: MiEmoji[];
|
||||||
|
|
||||||
if (ps.query) {
|
if (ps.query) {
|
||||||
//q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
|
||||||
//const emojis = await q.limit(ps.limit).getMany();
|
|
||||||
|
|
||||||
emojis = await q.getMany();
|
emojis = await q.getMany();
|
||||||
const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g);
|
const queries = ps.query.match(/:([a-z0-9_]*):/g);
|
||||||
|
if (queries) {
|
||||||
if (queryarry) {
|
|
||||||
emojis = emojis.filter(emoji =>
|
emojis = emojis.filter(emoji =>
|
||||||
queryarry.includes(`:${emoji.name}:`),
|
queries.includes(`:${emoji.name}:`),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
emojis = emojis.filter(emoji =>
|
emojis = emojis.filter(emoji =>
|
||||||
|
|
|
@ -25,18 +25,9 @@ export const meta = {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
items: {
|
items: {
|
||||||
anyOf: [
|
type: 'object',
|
||||||
{
|
optional: false, nullable: false,
|
||||||
type: 'object',
|
ref: 'EmojiSimple',
|
||||||
optional: false, nullable: false,
|
|
||||||
ref: 'EmojiSimple',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'object',
|
|
||||||
optional: false, nullable: false,
|
|
||||||
ref: 'EmojiDetailed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -46,10 +37,6 @@ export const meta = {
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
detail: {
|
|
||||||
type: 'boolean',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -74,9 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emojis: ps.detail
|
emojis: await this.emojiEntityService.packSimpleMany(emojis),
|
||||||
? await this.emojiEntityService.packDetailedMany(emojis)
|
|
||||||
: await this.emojiEntityService.packSimpleMany(emojis),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
{{ cell.value }}
|
{{ cell.value }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="cellType === 'boolean'">
|
<div v-else-if="cellType === 'boolean'">
|
||||||
<span v-if="cell.value" class="ti ti-check"/>
|
<span v-if="cell.value === true" class="ti ti-check"/>
|
||||||
<span v-else class="ti ti-x"/>
|
<span v-else class="ti ti-x"/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="cellType === 'image'">
|
<div v-else-if="cellType === 'image'">
|
||||||
|
@ -49,18 +49,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, toRefs, watch } from 'vue';
|
import { computed, nextTick, ref, toRefs, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
CellAddress,
|
|
||||||
CellValue,
|
CellValue,
|
||||||
equalCellAddress,
|
equalCellAddress,
|
||||||
getCellAddress,
|
getCellAddress,
|
||||||
GridCell,
|
GridCell,
|
||||||
GridEventEmitter, Size,
|
GridEventEmitter,
|
||||||
|
Size,
|
||||||
} from '@/components/grid/types.js';
|
} from '@/components/grid/types.js';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||||
(ev: 'operation:selectionMove', sender: GridCell, next: CellAddress): void;
|
|
||||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
|
@ -2,9 +2,7 @@
|
||||||
<tr :class="$style.row">
|
<tr :class="$style.row">
|
||||||
<MkNumberCell
|
<MkNumberCell
|
||||||
:content="(row.index + 1).toString()"
|
:content="(row.index + 1).toString()"
|
||||||
:selectable="true"
|
|
||||||
:row="row"
|
:row="row"
|
||||||
@operation:selectionRow="(sender) => emit('operation:selectionRow', sender)"
|
|
||||||
/>
|
/>
|
||||||
<MkDataCell
|
<MkDataCell
|
||||||
v-for="cell in cells"
|
v-for="cell in cells"
|
||||||
|
@ -13,7 +11,6 @@
|
||||||
:bus="bus"
|
:bus="bus"
|
||||||
@operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
|
@operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
|
||||||
@operation:endEdit="(sender) => emit('operation:endEdit', sender)"
|
@operation:endEdit="(sender) => emit('operation:endEdit', sender)"
|
||||||
@operation:selectionMove="(sender, next) => emit('operation:selectionMove', sender, next)"
|
|
||||||
@change:value="(sender, newValue) => emit('change:value', sender, newValue)"
|
@change:value="(sender, newValue) => emit('change:value', sender, newValue)"
|
||||||
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
||||||
/>
|
/>
|
||||||
|
@ -21,16 +18,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, toRefs } from 'vue';
|
import { toRefs } from 'vue';
|
||||||
import { CellAddress, CellValue, GridCell, GridEventEmitter, GridRow, Size } from '@/components/grid/types.js';
|
import { CellValue, GridCell, GridEventEmitter, GridRow, Size } from '@/components/grid/types.js';
|
||||||
import MkDataCell from '@/components/grid/MkDataCell.vue';
|
import MkDataCell from '@/components/grid/MkDataCell.vue';
|
||||||
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||||
(ev: 'operation:selectionRow', sender: GridRow): void;
|
|
||||||
(ev: 'operation:selectionMove', sender: GridCell, next: CellAddress): void;
|
|
||||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<table
|
<table
|
||||||
|
ref="rootEl"
|
||||||
|
tabindex="-1"
|
||||||
:class="$style.grid"
|
:class="$style.grid"
|
||||||
@mousedown="onMouseDown"
|
@mousedown="onMouseDown"
|
||||||
@keydown="onKeyDown"
|
@keydown="onKeyDown"
|
||||||
|
@ -11,7 +13,6 @@
|
||||||
@operation:beginWidthChange="onHeaderCellWidthBeginChange"
|
@operation:beginWidthChange="onHeaderCellWidthBeginChange"
|
||||||
@operation:endWidthChange="onHeaderCellWidthEndChange"
|
@operation:endWidthChange="onHeaderCellWidthEndChange"
|
||||||
@operation:widthLargest="onHeaderCellWidthLargest"
|
@operation:widthLargest="onHeaderCellWidthLargest"
|
||||||
@operation:selectionColumn="onSelectionColumn"
|
|
||||||
@change:width="onHeaderCellChangeWidth"
|
@change:width="onHeaderCellChangeWidth"
|
||||||
@change:contentSize="onHeaderCellChangeContentSize"
|
@change:contentSize="onHeaderCellChangeContentSize"
|
||||||
/>
|
/>
|
||||||
|
@ -25,8 +26,6 @@
|
||||||
:bus="bus"
|
:bus="bus"
|
||||||
@operation:beginEdit="onCellEditBegin"
|
@operation:beginEdit="onCellEditBegin"
|
||||||
@operation:endEdit="onCellEditEnd"
|
@operation:endEdit="onCellEditEnd"
|
||||||
@operation:selectionMove="onSelectionMove"
|
|
||||||
@operation:selectionRow="onSelectionRow"
|
|
||||||
@change:value="onChangeCellValue"
|
@change:value="onChangeCellValue"
|
||||||
@change:contentSize="onChangeCellContentSize"
|
@change:contentSize="onChangeCellContentSize"
|
||||||
/>
|
/>
|
||||||
|
@ -40,7 +39,7 @@ import {
|
||||||
calcCellWidth,
|
calcCellWidth,
|
||||||
CELL_ADDRESS_NONE,
|
CELL_ADDRESS_NONE,
|
||||||
CellAddress,
|
CellAddress,
|
||||||
CellValue,
|
CellValue, CellValueChangedEvent,
|
||||||
ColumnSetting,
|
ColumnSetting,
|
||||||
DataSource,
|
DataSource,
|
||||||
equalCellAddress,
|
equalCellAddress,
|
||||||
|
@ -54,16 +53,22 @@ import {
|
||||||
} from '@/components/grid/types.js';
|
} from '@/components/grid/types.js';
|
||||||
import MkDataRow from '@/components/grid/MkDataRow.vue';
|
import MkDataRow from '@/components/grid/MkDataRow.vue';
|
||||||
import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
|
import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
columnSettings: ColumnSetting[],
|
columnSettings: ColumnSetting[],
|
||||||
data: DataSource[]
|
data: DataSource[]
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'change:cellValue', event: CellValueChangedEvent): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const bus = new GridEventEmitter();
|
const bus = new GridEventEmitter();
|
||||||
|
|
||||||
const { columnSettings, data } = toRefs(props);
|
const { columnSettings, data } = toRefs(props);
|
||||||
|
|
||||||
|
const rootEl = ref<InstanceType<typeof HTMLTableElement>>();
|
||||||
const columns = ref<GridColumn[]>([]);
|
const columns = ref<GridColumn[]>([]);
|
||||||
const rows = ref<GridRow[]>([]);
|
const rows = ref<GridRow[]>([]);
|
||||||
const cells = ref<GridCell[][]>([]);
|
const cells = ref<GridCell[][]>([]);
|
||||||
|
@ -78,9 +83,36 @@ const selectedCell = computed(() => {
|
||||||
return selected.length > 0 ? selected[0] : undefined;
|
return selected.length > 0 ? selected[0] : undefined;
|
||||||
});
|
});
|
||||||
const rangedCells = computed(() => cells.value.flat().filter(it => it.ranged));
|
const rangedCells = computed(() => cells.value.flat().filter(it => it.ranged));
|
||||||
|
const rangedBounds = computed(() => {
|
||||||
|
const _cells = rangedCells.value;
|
||||||
|
const leftTop = {
|
||||||
|
col: Math.min(..._cells.map(it => it.address.col)),
|
||||||
|
row: Math.min(..._cells.map(it => it.address.row)),
|
||||||
|
};
|
||||||
|
const rightBottom = {
|
||||||
|
col: Math.max(..._cells.map(it => it.address.col)),
|
||||||
|
row: Math.max(..._cells.map(it => it.address.row)),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
leftTop,
|
||||||
|
rightBottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const availableBounds = computed(() => {
|
||||||
|
const leftTop = {
|
||||||
|
col: 0,
|
||||||
|
row: 0,
|
||||||
|
};
|
||||||
|
const rightBottom = {
|
||||||
|
col: Math.max(...columns.value.map(it => it.index)),
|
||||||
|
row: Math.max(...rows.value.map(it => it.index)),
|
||||||
|
};
|
||||||
|
return { leftTop, rightBottom };
|
||||||
|
});
|
||||||
|
|
||||||
watch(columnSettings, refreshColumnsSetting);
|
watch(columnSettings, refreshColumnsSetting);
|
||||||
watch(data, refreshData);
|
watch(data, refreshData);
|
||||||
|
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
watch(state, (value) => {
|
watch(state, (value) => {
|
||||||
console.log(`state: ${value}`);
|
console.log(`state: ${value}`);
|
||||||
|
@ -88,37 +120,189 @@ if (_DEV_) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(ev: KeyboardEvent) {
|
function onKeyDown(ev: KeyboardEvent) {
|
||||||
|
if (_DEV_) {
|
||||||
|
console.log('[Grid]', `ctrl: ${ev.ctrlKey}, shift: ${ev.shiftKey}, code: ${ev.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
switch (state.value) {
|
switch (state.value) {
|
||||||
case 'normal': {
|
case 'normal': {
|
||||||
const selectedCellAddress = selectedCell.value?.address;
|
// normalの時は自前で制御したい
|
||||||
if (!selectedCellAddress) {
|
ev.preventDefault();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next: CellAddress;
|
if (ev.ctrlKey) {
|
||||||
switch (ev.code) {
|
if (ev.shiftKey) {
|
||||||
case 'ArrowRight': {
|
// ctrl + shiftキーが押されている場合は選択セルの範囲拡大(最大範囲)
|
||||||
next = { col: selectedCellAddress.col + 1, row: selectedCellAddress.row };
|
const selectedCellAddress = requireSelectionCell();
|
||||||
break;
|
const max = availableBounds.value;
|
||||||
|
const bounds = rangedBounds.value;
|
||||||
|
|
||||||
|
let newBounds: { leftTop: CellAddress, rightBottom: CellAddress };
|
||||||
|
switch (ev.code) {
|
||||||
|
case 'ArrowRight': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: { col: selectedCellAddress.col, row: bounds.leftTop.row },
|
||||||
|
rightBottom: { col: max.rightBottom.col, row: bounds.rightBottom.row },
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: { col: max.leftTop.col, row: bounds.leftTop.row },
|
||||||
|
rightBottom: { col: selectedCellAddress.col, row: bounds.rightBottom.row },
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: { col: bounds.leftTop.col, row: max.leftTop.row },
|
||||||
|
rightBottom: { col: bounds.rightBottom.col, row: selectedCellAddress.row },
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowDown': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: { col: bounds.leftTop.col, row: selectedCellAddress.row },
|
||||||
|
rightBottom: { col: bounds.rightBottom.col, row: max.rightBottom.row },
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom);
|
||||||
|
expandRange(newBounds.leftTop, newBounds.rightBottom);
|
||||||
|
} else {
|
||||||
|
switch (ev.code) {
|
||||||
|
case 'KeyC': {
|
||||||
|
rangeCopyToClipboard();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'KeyV': {
|
||||||
|
pasteFromClipboard();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case 'ArrowLeft': {
|
} else {
|
||||||
next = { col: selectedCellAddress.col - 1, row: selectedCellAddress.row };
|
if (ev.shiftKey) {
|
||||||
break;
|
// shiftキーが押されている場合は選択セルの範囲拡大(隣のセルまで)
|
||||||
}
|
const selectedCellAddress = requireSelectionCell();
|
||||||
case 'ArrowUp': {
|
const bounds = rangedBounds.value;
|
||||||
next = { col: selectedCellAddress.col, row: selectedCellAddress.row - 1 };
|
let newBounds: { leftTop: CellAddress, rightBottom: CellAddress };
|
||||||
break;
|
switch (ev.code) {
|
||||||
}
|
case 'ArrowRight': {
|
||||||
case 'ArrowDown': {
|
newBounds = {
|
||||||
next = { col: selectedCellAddress.col, row: selectedCellAddress.row + 1 };
|
leftTop: {
|
||||||
break;
|
col: bounds.leftTop.col < selectedCellAddress.col
|
||||||
}
|
? bounds.leftTop.col + 1
|
||||||
default: {
|
: selectedCellAddress.col,
|
||||||
return;
|
row: bounds.leftTop.row,
|
||||||
|
},
|
||||||
|
rightBottom: {
|
||||||
|
col: (bounds.rightBottom.col > selectedCellAddress.col || bounds.leftTop.col === selectedCellAddress.col)
|
||||||
|
? bounds.rightBottom.col + 1
|
||||||
|
: selectedCellAddress.col,
|
||||||
|
row: bounds.rightBottom.row,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: {
|
||||||
|
col: (bounds.leftTop.col < selectedCellAddress.col || bounds.rightBottom.col === selectedCellAddress.col)
|
||||||
|
? bounds.leftTop.col - 1
|
||||||
|
: selectedCellAddress.col,
|
||||||
|
row: bounds.leftTop.row,
|
||||||
|
},
|
||||||
|
rightBottom: {
|
||||||
|
col: bounds.rightBottom.col > selectedCellAddress.col
|
||||||
|
? bounds.rightBottom.col - 1
|
||||||
|
: selectedCellAddress.col,
|
||||||
|
row: bounds.rightBottom.row,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: {
|
||||||
|
col: bounds.leftTop.col,
|
||||||
|
row: (bounds.leftTop.row < selectedCellAddress.row || bounds.rightBottom.row === selectedCellAddress.row)
|
||||||
|
? bounds.leftTop.row - 1
|
||||||
|
: selectedCellAddress.row,
|
||||||
|
},
|
||||||
|
rightBottom: {
|
||||||
|
col: bounds.rightBottom.col,
|
||||||
|
row: bounds.rightBottom.row > selectedCellAddress.row
|
||||||
|
? bounds.rightBottom.row - 1
|
||||||
|
: selectedCellAddress.row,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowDown': {
|
||||||
|
newBounds = {
|
||||||
|
leftTop: {
|
||||||
|
col: bounds.leftTop.col,
|
||||||
|
row: bounds.leftTop.row < selectedCellAddress.row
|
||||||
|
? bounds.leftTop.row + 1
|
||||||
|
: selectedCellAddress.row,
|
||||||
|
},
|
||||||
|
rightBottom: {
|
||||||
|
col: bounds.rightBottom.col,
|
||||||
|
row: (bounds.rightBottom.row > selectedCellAddress.row || bounds.leftTop.row === selectedCellAddress.row)
|
||||||
|
? bounds.rightBottom.row + 1
|
||||||
|
: selectedCellAddress.row,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom);
|
||||||
|
expandRange(newBounds.leftTop, newBounds.rightBottom);
|
||||||
|
} else {
|
||||||
|
// shiftキーもctrlキーが押されていない場合
|
||||||
|
switch (ev.code) {
|
||||||
|
case 'ArrowRight': {
|
||||||
|
const selectedCellAddress = requireSelectionCell();
|
||||||
|
selectionCell({ col: selectedCellAddress.col + 1, row: selectedCellAddress.row });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
const selectedCellAddress = requireSelectionCell();
|
||||||
|
selectionCell({ col: selectedCellAddress.col - 1, row: selectedCellAddress.row });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
const selectedCellAddress = requireSelectionCell();
|
||||||
|
selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row - 1 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowDown': {
|
||||||
|
const selectedCellAddress = requireSelectionCell();
|
||||||
|
selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row + 1 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Delete': {
|
||||||
|
const ranges = rangedCells.value;
|
||||||
|
for (const range of ranges) {
|
||||||
|
range.value = undefined;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionCell(next);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -134,7 +318,6 @@ function onMouseDown(ev: MouseEvent) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'normal': {
|
case 'normal': {
|
||||||
const cellAddress = getCellAddress(ev.target as HTMLElement);
|
|
||||||
if (availableCellAddress(cellAddress)) {
|
if (availableCellAddress(cellAddress)) {
|
||||||
selectionCell(cellAddress);
|
selectionCell(cellAddress);
|
||||||
|
|
||||||
|
@ -151,6 +334,8 @@ function onMouseDown(ev: MouseEvent) {
|
||||||
registerMouseMove();
|
registerMouseMove();
|
||||||
firstSelectionColumnIdx.value = cellAddress.col;
|
firstSelectionColumnIdx.value = cellAddress.col;
|
||||||
state.value = 'colSelecting';
|
state.value = 'colSelecting';
|
||||||
|
|
||||||
|
rootEl.value?.focus();
|
||||||
} else if (isRowNumberCellAddress(cellAddress)) {
|
} else if (isRowNumberCellAddress(cellAddress)) {
|
||||||
unSelectionRange();
|
unSelectionRange();
|
||||||
|
|
||||||
|
@ -161,6 +346,8 @@ function onMouseDown(ev: MouseEvent) {
|
||||||
registerMouseMove();
|
registerMouseMove();
|
||||||
firstSelectionRowIdx.value = cellAddress.row;
|
firstSelectionRowIdx.value = cellAddress.row;
|
||||||
state.value = 'rowSelecting';
|
state.value = 'rowSelecting';
|
||||||
|
|
||||||
|
rootEl.value?.focus();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -168,6 +355,7 @@ function onMouseDown(ev: MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(ev: MouseEvent) {
|
function onMouseMove(ev: MouseEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
switch (state.value) {
|
switch (state.value) {
|
||||||
case 'cellSelecting': {
|
case 'cellSelecting': {
|
||||||
const selectedCellAddress = selectedCell.value?.address;
|
const selectedCellAddress = selectedCell.value?.address;
|
||||||
|
@ -240,6 +428,7 @@ function onMouseMove(ev: MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp(ev: MouseEvent) {
|
function onMouseUp(ev: MouseEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
switch (state.value) {
|
switch (state.value) {
|
||||||
case 'rowSelecting':
|
case 'rowSelecting':
|
||||||
case 'colSelecting':
|
case 'colSelecting':
|
||||||
|
@ -270,20 +459,14 @@ function onCellEditEnd() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChangeCellValue(sender: GridCell, newValue: CellValue) {
|
function onChangeCellValue(sender: GridCell, newValue: CellValue) {
|
||||||
cells.value[sender.address.row][sender.address.col].value = newValue;
|
setCellValue(sender, newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChangeCellContentSize(sender: GridCell, contentSize: Size) {
|
function onChangeCellContentSize(sender: GridCell, contentSize: Size) {
|
||||||
cells.value[sender.address.row][sender.address.col].contentSize = contentSize;
|
cells.value[sender.address.row][sender.address.col].contentSize = contentSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectionMove(_: GridCell, next: CellAddress) {
|
function onHeaderCellWidthBeginChange() {
|
||||||
if (availableCellAddress(next)) {
|
|
||||||
selectionCell(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHeaderCellWidthBeginChange(_: GridColumn) {
|
|
||||||
switch (state.value) {
|
switch (state.value) {
|
||||||
case 'normal': {
|
case 'normal': {
|
||||||
state.value = 'colResizing';
|
state.value = 'colResizing';
|
||||||
|
@ -292,7 +475,7 @@ function onHeaderCellWidthBeginChange(_: GridColumn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHeaderCellWidthEndChange(_: GridColumn) {
|
function onHeaderCellWidthEndChange() {
|
||||||
switch (state.value) {
|
switch (state.value) {
|
||||||
case 'colResizing': {
|
case 'colResizing': {
|
||||||
state.value = 'normal';
|
state.value = 'normal';
|
||||||
|
@ -341,18 +524,14 @@ function onHeaderCellWidthLargest(sender: GridColumn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectionColumn(sender: GridColumn) {
|
function setCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
|
||||||
unSelectionRange();
|
const cellAddress = 'address' in sender ? sender.address : sender;
|
||||||
|
cells.value[cellAddress.row][cellAddress.col].value = newValue;
|
||||||
const targets = cells.value.map(row => row[sender.index].address);
|
emit('change:cellValue', {
|
||||||
selectionRange(...targets);
|
column: columns.value[cellAddress.col],
|
||||||
}
|
row: rows.value[cellAddress.row],
|
||||||
|
value: newValue,
|
||||||
function onSelectionRow(sender: GridRow) {
|
});
|
||||||
unSelectionRange();
|
|
||||||
|
|
||||||
const targets = cells.value[sender.index].map(cell => cell.address);
|
|
||||||
selectionRange(...targets);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectionCell(target: CellAddress) {
|
function selectionCell(target: CellAddress) {
|
||||||
|
@ -367,6 +546,15 @@ function selectionCell(target: CellAddress) {
|
||||||
_cells[target.row][target.col].ranged = true;
|
_cells[target.row][target.col].ranged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireSelectionCell(): CellAddress {
|
||||||
|
const selected = selectedCell.value;
|
||||||
|
if (!selected) {
|
||||||
|
throw new Error('No selected cell');
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected.address;
|
||||||
|
}
|
||||||
|
|
||||||
function selectionRange(...targets: CellAddress[]) {
|
function selectionRange(...targets: CellAddress[]) {
|
||||||
const _cells = cells.value;
|
const _cells = cells.value;
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
|
@ -414,6 +602,75 @@ function isRowNumberCellAddress(cellAddress: CellAddress): boolean {
|
||||||
return cellAddress.row >= 0 && cellAddress.col === -1;
|
return cellAddress.row >= 0 && cellAddress.col === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rangeCopyToClipboard() {
|
||||||
|
const lines = Array.of<string>();
|
||||||
|
const bounds = rangedBounds.value;
|
||||||
|
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
|
||||||
|
const items = Array.of<string>();
|
||||||
|
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
|
||||||
|
const cell = cells.value[row][col];
|
||||||
|
items.push(cell.value?.toString() ?? '');
|
||||||
|
}
|
||||||
|
lines.push(items.join('\t'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = lines.join('\n');
|
||||||
|
copyToClipboard(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteFromClipboard() {
|
||||||
|
function parseValue(value: string, type: ColumnSetting['type']): CellValue {
|
||||||
|
switch (type) {
|
||||||
|
case 'number': {
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
case 'boolean': {
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipBoardText = await navigator.clipboard.readText();
|
||||||
|
|
||||||
|
const bounds = rangedBounds.value;
|
||||||
|
const lines = clipBoardText.replace(/\r/g, '')
|
||||||
|
.split('\n')
|
||||||
|
.map(it => it.split('\t'));
|
||||||
|
|
||||||
|
if (lines.length === 1 && lines[0].length === 1) {
|
||||||
|
// 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
|
||||||
|
const ranges = rangedCells.value;
|
||||||
|
for (const cell of ranges) {
|
||||||
|
setCellValue(cell, parseValue(lines[0][0], cell.column.setting.type));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
|
||||||
|
const offsetRow = bounds.leftTop.row;
|
||||||
|
const offsetCol = bounds.leftTop.col;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCellValue(cells.value[row][col], parseValue(items[colIdx], cells.value[row][col].column.setting.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshColumnsSetting() {
|
function refreshColumnsSetting() {
|
||||||
const bindToList = columnSettings.value.map(it => it.bindTo);
|
const bindToList = columnSettings.value.map(it => it.bindTo);
|
||||||
if (new Set(bindToList).size !== columnSettings.value.length) {
|
if (new Set(bindToList).size !== columnSettings.value.length) {
|
||||||
|
@ -427,6 +684,7 @@ function refreshData() {
|
||||||
const _data: DataSource[] = data.value;
|
const _data: DataSource[] = data.value;
|
||||||
const _rows: GridRow[] = _data.map((_, index) => ({
|
const _rows: GridRow[] = _data.map((_, index) => ({
|
||||||
index,
|
index,
|
||||||
|
|
||||||
}));
|
}));
|
||||||
const _columns: GridColumn[] = columnSettings.value.map((setting, index) => ({
|
const _columns: GridColumn[] = columnSettings.value.map((setting, index) => ({
|
||||||
index,
|
index,
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<tr :class="$style.header">
|
<tr :class="$style.header">
|
||||||
<MkNumberCell
|
<MkNumberCell
|
||||||
content="#"
|
content="#"
|
||||||
:selectable="false"
|
|
||||||
:top="true"
|
:top="true"
|
||||||
/>
|
/>
|
||||||
<MkHeaderCell
|
<MkHeaderCell
|
||||||
|
|
|
@ -7,12 +7,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GridRow } from '@/components/grid/types.js';
|
import { GridRow } from '@/components/grid/types.js';
|
||||||
|
|
||||||
const emit = defineEmits<{}>();
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
content: string,
|
content: string,
|
||||||
row?: GridRow,
|
row?: GridRow,
|
||||||
selectable: boolean,
|
|
||||||
top?: boolean,
|
top?: boolean,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
|
@ -56,6 +56,12 @@ export type GridRow = {
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CellValueChangedEvent = {
|
||||||
|
column: GridColumn;
|
||||||
|
row: GridRow;
|
||||||
|
value: CellValue;
|
||||||
|
}
|
||||||
|
|
||||||
export class GridEventEmitter extends EventEmitter<{}> {
|
export class GridEventEmitter extends EventEmitter<{}> {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +77,7 @@ export function isRowElement(elem: any): elem is HTMLTableRowElement {
|
||||||
return elem instanceof HTMLTableRowElement;
|
return elem instanceof HTMLTableRowElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCellAddress(elem: HTMLElement, parentNodeCount = 5): CellAddress {
|
export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
|
||||||
let node = elem;
|
let node = elem;
|
||||||
for (let i = 0; i < parentNodeCount; i++) {
|
for (let i = 0; i < parentNodeCount; i++) {
|
||||||
if (isCellElement(node) && isRowElement(node.parentElement)) {
|
if (isCellElement(node) && isRowElement(node.parentElement)) {
|
||||||
|
|
|
@ -1,9 +1,23 @@
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
|
||||||
export class GridItem {
|
export interface IGridItem {
|
||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
readonly url?: string;
|
readonly fileId?: string;
|
||||||
readonly blob?: Blob;
|
readonly url: string;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
aliases: string;
|
||||||
|
license: string;
|
||||||
|
isSensitive: boolean;
|
||||||
|
localOnly: boolean;
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GridItem implements IGridItem {
|
||||||
|
readonly id?: string;
|
||||||
|
readonly fileId?: string;
|
||||||
|
readonly url: string;
|
||||||
|
|
||||||
public name: string;
|
public name: string;
|
||||||
public category: string;
|
public category: string;
|
||||||
|
@ -17,8 +31,8 @@ export class GridItem {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: string | undefined,
|
id: string | undefined,
|
||||||
url: string | undefined = undefined,
|
fileId: string | undefined,
|
||||||
blob: Blob | undefined = undefined,
|
url: string,
|
||||||
name: string,
|
name: string,
|
||||||
category: string,
|
category: string,
|
||||||
aliases: string,
|
aliases: string,
|
||||||
|
@ -28,8 +42,8 @@ export class GridItem {
|
||||||
roleIdsThatCanBeUsedThisEmojiAsReaction: string,
|
roleIdsThatCanBeUsedThisEmojiAsReaction: string,
|
||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
this.fileId = fileId;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.blob = blob;
|
|
||||||
|
|
||||||
this.aliases = aliases;
|
this.aliases = aliases;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
@ -42,11 +56,11 @@ export class GridItem {
|
||||||
this.origin = JSON.stringify(this);
|
this.origin = JSON.stringify(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ofEmojiDetailed(it: Misskey.entities.EmojiDetailed): GridItem {
|
static fromEmojiDetailed(it: Misskey.entities.EmojiDetailed): GridItem {
|
||||||
return new GridItem(
|
return new GridItem(
|
||||||
it.id,
|
it.id,
|
||||||
it.url,
|
|
||||||
undefined,
|
undefined,
|
||||||
|
it.url,
|
||||||
it.name,
|
it.name,
|
||||||
it.category ?? '',
|
it.category ?? '',
|
||||||
it.aliases.join(', '),
|
it.aliases.join(', '),
|
||||||
|
@ -57,6 +71,21 @@ export class GridItem {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fromDriveFile(it: Misskey.entities.DriveFile): GridItem {
|
||||||
|
return new GridItem(
|
||||||
|
undefined,
|
||||||
|
it.id,
|
||||||
|
it.url,
|
||||||
|
it.name.replace(/\.[a-zA-Z0-9]+$/, ''),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
it.isSensitive,
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public get edited(): boolean {
|
public get edited(): boolean {
|
||||||
const { origin, ..._this } = this;
|
const { origin, ..._this } = this;
|
||||||
return JSON.stringify(_this) !== origin;
|
return JSON.stringify(_this) !== origin;
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
<template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkInput :modelValue="query" :debounce="true" type="search" autocapitalize="off" @change="(v) => query = v">
|
||||||
|
<template #prefix><i class="ti ti-search"></i></template>
|
||||||
|
<template #label>{{ i18n.ts.search }}</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="overflow-y: scroll; padding-top: 8px; padding-bottom: 8px;"
|
||||||
|
>
|
||||||
|
<MkGrid :data="convertedGridItems" :columnSettings="columnSettings"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="$style.pages">
|
||||||
|
<button><<</button>
|
||||||
|
<button><</button>
|
||||||
|
|
||||||
|
<button>1</button>
|
||||||
|
<button>2</button>
|
||||||
|
<button>3</button>
|
||||||
|
<button>4</button>
|
||||||
|
<button>5</button>
|
||||||
|
<span>...</span>
|
||||||
|
<button>10</button>
|
||||||
|
|
||||||
|
<button>></button>
|
||||||
|
<button>>></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, onMounted, ref, toRefs, watch } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import { GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
|
||||||
|
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||||
|
import { ColumnSetting } from '@/components/grid/types.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
|
||||||
|
const columnSettings: ColumnSetting[] = [
|
||||||
|
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
|
||||||
|
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
||||||
|
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
||||||
|
{ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
customEmojis: Misskey.entities.EmojiDetailed[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { customEmojis } = toRefs(props);
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const gridItems = ref<GridItem[]>([]);
|
||||||
|
|
||||||
|
const convertedGridItems = computed(() => gridItems.value.map(it => it.asRecord()));
|
||||||
|
|
||||||
|
watch(customEmojis, refreshGridItems);
|
||||||
|
|
||||||
|
function refreshGridItems() {
|
||||||
|
gridItems.value = customEmojis.value.map(it => GridItem.fromEmojiDetailed(it));
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshGridItems();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: var(--buttonBg);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: none;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,218 @@
|
||||||
|
<template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkFolder>
|
||||||
|
<template #icon><i class="ti ti-settings"></i></template>
|
||||||
|
<template #label>アップロード設定</template>
|
||||||
|
<template #caption>この画面で絵文字アップロードを行う際の動作を設定できます。</template>
|
||||||
|
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkSelect v-model="selectedFolderId">
|
||||||
|
<template #label>{{ i18n.ts.uploadFolder }}</template>
|
||||||
|
<option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
|
||||||
|
{{ folder.name }}
|
||||||
|
</option>
|
||||||
|
</MkSelect>
|
||||||
|
|
||||||
|
<MkSwitch v-model="keepOriginalUploading">
|
||||||
|
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
|
||||||
|
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class="$style.uploadBox"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent.stop="onDrop"
|
||||||
|
>
|
||||||
|
ここに絵文字の画像ファイルをドラッグ&ドロップするとドライブにアップロードされます。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="gridItems.length > 0"
|
||||||
|
style="overflow-y: scroll;"
|
||||||
|
>
|
||||||
|
<MkGrid
|
||||||
|
:data="convertedGridItems"
|
||||||
|
:columnSettings="columnSettings"
|
||||||
|
@change:cellValue="onChangeCellValue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="gridItems.length > 0"
|
||||||
|
:class="$style.buttons"
|
||||||
|
>
|
||||||
|
<MkButton primary @click="onRegistryClicked">{{ i18n.ts.registration }}</MkButton>
|
||||||
|
<MkButton @click="onClearClicked">{{ i18n.ts.clear }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
|
import { GridItem, IGridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
|
||||||
|
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||||
|
import { CellValueChangedEvent, ColumnSetting } from '@/components/grid/types.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
|
import { uploadFile } from '@/scripts/upload.js';
|
||||||
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import { defaultStore } from '@/store.js';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
|
type FolderItem = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadResult = { key: string, item: IGridItem, success: boolean, err: any };
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'operation:registered'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const columnSettings: ColumnSetting[] = [
|
||||||
|
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
|
||||||
|
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
||||||
|
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
||||||
|
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
||||||
|
{ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const uploadFolders = ref<FolderItem[]>([]);
|
||||||
|
const gridItems = ref<IGridItem[]>([]);
|
||||||
|
const selectedFolderId = ref(defaultStore.state.uploadFolder);
|
||||||
|
const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
|
||||||
|
const convertedGridItems = computed(() => gridItems.value.map(it => it as Record<string, any>));
|
||||||
|
|
||||||
|
async function onRegistryClicked() {
|
||||||
|
const dialogSelection = await os.confirm({
|
||||||
|
type: 'info',
|
||||||
|
title: '確認',
|
||||||
|
text: 'リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dialogSelection.canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = new Map<string, IGridItem>(gridItems.value.map(it => [`${it.fileId}|${it.name}`, it]));
|
||||||
|
const upload = async (): Promise<UploadResult[]> => {
|
||||||
|
const result = Array.of<UploadResult>();
|
||||||
|
for (const [key, item] of items.entries()) {
|
||||||
|
try {
|
||||||
|
await misskeyApi('admin/emoji/add', {
|
||||||
|
name: item.name,
|
||||||
|
category: item.category,
|
||||||
|
aliases: item.aliases.split(',').map(it => it.trim()),
|
||||||
|
license: item.license,
|
||||||
|
isSensitive: item.isSensitive,
|
||||||
|
localOnly: item.localOnly,
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.split(',').map(it => it.trim()),
|
||||||
|
fileId: item.fileId!,
|
||||||
|
});
|
||||||
|
result.push({ key, item, success: true, err: undefined });
|
||||||
|
} catch (err: any) {
|
||||||
|
result.push({ key, item, success: false, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await os.promiseDialog(upload());
|
||||||
|
const failedItems = result.filter(it => !it.success);
|
||||||
|
|
||||||
|
gridItems.value = failedItems.map(it => it.item);
|
||||||
|
|
||||||
|
emit('operation:registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearClicked() {
|
||||||
|
const result = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
title: '確認',
|
||||||
|
text: '編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
gridItems.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(ev: DragEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const dropFiles = ev.dataTransfer?.files;
|
||||||
|
if (!dropFiles) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadingPromises = Array.of<Promise<Misskey.entities.DriveFile>>();
|
||||||
|
for (let i = 0; i < dropFiles.length; i++) {
|
||||||
|
const file = dropFiles.item(i);
|
||||||
|
if (file) {
|
||||||
|
const name = file.name.replace(/\.[a-zA-Z0-9]+$/, '');
|
||||||
|
uploadingPromises.push(
|
||||||
|
uploadFile(
|
||||||
|
file,
|
||||||
|
selectedFolderId.value,
|
||||||
|
name,
|
||||||
|
keepOriginalUploading.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedFiles = await Promise.all(uploadingPromises);
|
||||||
|
for (const uploadedFile of uploadedFiles) {
|
||||||
|
const item = GridItem.fromDriveFile(uploadedFile);
|
||||||
|
gridItems.value.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeCellValue(event: CellValueChangedEvent) {
|
||||||
|
console.log(event);
|
||||||
|
const item = gridItems.value[event.row.index];
|
||||||
|
item[event.column.setting.bindTo] = event.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUploadFolders() {
|
||||||
|
const result = await misskeyApi('drive/folders', {});
|
||||||
|
uploadFolders.value = Array.of<FolderItem>({ name: '-' }, ...result);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshUploadFolders();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.uploadBox {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
border: 0.5px dotted var(--accentedBg);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--accentedBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,38 +2,17 @@
|
||||||
<div>
|
<div>
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header>
|
<template #header>
|
||||||
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
|
<MkPageHeader v-model:tab="headerTab" :actions="headerActions" :tabs="headerTabs"/>
|
||||||
</template>
|
</template>
|
||||||
<div class="_gaps" :class="$style.root">
|
<div class="_gaps" :class="$style.root">
|
||||||
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
|
<MkTab v-model="modeTab" style="margin-bottom: var(--margin);">
|
||||||
<template #prefix><i class="ti ti-search"></i></template>
|
<option value="list">登録済み絵文字一覧</option>
|
||||||
<template #label>{{ i18n.ts.search }}</template>
|
<option value="register">新規登録</option>
|
||||||
</MkInput>
|
</MkTab>
|
||||||
|
|
||||||
<div :class="$style.controller">
|
<div>
|
||||||
<MkSelect v-model="limit">
|
<XListComponent v-if="modeTab === 'list'" :customEmojis="customEmojis"/>
|
||||||
<option value="100">100件</option>
|
<XRegisterComponent v-else @operation:registered="onOperationRegistered"/>
|
||||||
</MkSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="overflow-y: scroll; padding-top: 8px; padding-bottom: 8px;">
|
|
||||||
<MkGrid :data="convertedGridItems" :columnSettings="columnSettings"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="$style.pages">
|
|
||||||
<button><<</button>
|
|
||||||
<button><</button>
|
|
||||||
|
|
||||||
<button>1</button>
|
|
||||||
<button>2</button>
|
|
||||||
<button>3</button>
|
|
||||||
<button>4</button>
|
|
||||||
<button>5</button>
|
|
||||||
<span>...</span>
|
|
||||||
<button>10</button>
|
|
||||||
|
|
||||||
<button>></button>
|
|
||||||
<button>>></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
|
@ -41,49 +20,31 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
|
|
||||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
|
||||||
import { ColumnSetting } from '@/components/grid/types.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
import MkTab from '@/components/MkTab.vue';
|
||||||
|
import XListComponent from '@/pages/admin/custom-emojis-grid.list.vue';
|
||||||
|
import XRegisterComponent from '@/pages/admin/custom-emojis-grid.register.vue';
|
||||||
|
|
||||||
const columnSettings: ColumnSetting[] = [
|
type PageMode = 'list' | 'register';
|
||||||
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
|
|
||||||
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
|
|
||||||
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
|
||||||
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
|
||||||
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
|
||||||
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
|
||||||
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
|
||||||
{ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const customEmojis = ref<Misskey.entities.EmojiDetailed[]>([]);
|
const customEmojis = ref<Misskey.entities.EmojiDetailed[]>([]);
|
||||||
const gridItems = ref<GridItem[]>([]);
|
const headerTab = ref('local');
|
||||||
const query = ref('');
|
const modeTab = ref<PageMode>('list');
|
||||||
const limit = ref(100);
|
|
||||||
const tab = ref('local');
|
|
||||||
|
|
||||||
const convertedGridItems = computed(() => gridItems.value.map(it => it.asRecord()));
|
async function refreshCustomEmojis() {
|
||||||
|
customEmojis.value = await misskeyApi('admin/emoji/list', { limit: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
const refreshCustomEmojis = async () => {
|
async function onOperationRegistered() {
|
||||||
customEmojis.value = await misskeyApi('emojis', { detail: true }).then(it => it.emojis);
|
await refreshCustomEmojis();
|
||||||
};
|
}
|
||||||
|
|
||||||
const refreshGridItems = () => {
|
|
||||||
gridItems.value = customEmojis.value.map(it => GridItem.ofEmojiDetailed(it));
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(customEmojis, refreshGridItems);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await refreshCustomEmojis();
|
await refreshCustomEmojis();
|
||||||
refreshGridItems();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerTabs = computed(() => [{
|
const headerTabs = computed(() => [{
|
||||||
|
@ -98,10 +59,13 @@ const headerActions = computed(() => [{
|
||||||
asFullButton: true,
|
asFullButton: true,
|
||||||
icon: 'ti ti-plus',
|
icon: 'ti ti-plus',
|
||||||
text: i18n.ts.addEmoji,
|
text: i18n.ts.addEmoji,
|
||||||
handler: () => {},
|
handler: () => {
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
icon: 'ti ti-dots',
|
icon: 'ti ti-dots',
|
||||||
handler: () => {},
|
text: '',
|
||||||
|
handler: () => {
|
||||||
|
},
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
definePageMetadata(computed(() => ({
|
definePageMetadata(computed(() => ({
|
||||||
|
|
|
@ -1005,9 +1005,6 @@ type EmojiResponse = operations['emoji']['responses']['200']['content']['applica
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type EmojiSimple = components['schemas']['EmojiSimple'];
|
type EmojiSimple = components['schemas']['EmojiSimple'];
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
type EmojisRequest = operations['emojis']['requestBody']['content']['application/json'];
|
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
|
type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
@ -1049,26 +1046,6 @@ export type Endpoints = Overwrite<Endpoints_2, {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
'emojis': {
|
|
||||||
req: EmojisRequest;
|
|
||||||
res: {
|
|
||||||
$switch: {
|
|
||||||
$cases: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
detail: true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
emojis: EmojiDetailed[];
|
|
||||||
}
|
|
||||||
]
|
|
||||||
];
|
|
||||||
$default: {
|
|
||||||
emojis: EmojiSimple[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
'signup': {
|
'signup': {
|
||||||
req: SignupRequest;
|
req: SignupRequest;
|
||||||
res: SignupResponse;
|
res: SignupResponse;
|
||||||
|
@ -1468,7 +1445,6 @@ declare namespace entities {
|
||||||
InviteLimitResponse,
|
InviteLimitResponse,
|
||||||
MetaRequest,
|
MetaRequest,
|
||||||
MetaResponse,
|
MetaResponse,
|
||||||
EmojisRequest,
|
|
||||||
EmojisResponse,
|
EmojisResponse,
|
||||||
EmojiRequest,
|
EmojiRequest,
|
||||||
EmojiResponse,
|
EmojiResponse,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Endpoints as Gen } from './autogen/endpoint.js';
|
import { Endpoints as Gen } from './autogen/endpoint.js';
|
||||||
import { EmojiDetailed, EmojiSimple, UserDetailed } from './autogen/models.js';
|
import { UserDetailed } from './autogen/models.js';
|
||||||
import { EmojisRequest, UsersShowRequest } from './autogen/entities.js';
|
import { UsersShowRequest } from './autogen/entities.js';
|
||||||
import {
|
import {
|
||||||
SigninRequest,
|
SigninRequest,
|
||||||
SigninResponse,
|
SigninResponse,
|
||||||
|
@ -64,24 +64,6 @@ export type Endpoints = Overwrite<
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
'emojis': {
|
|
||||||
req: EmojisRequest;
|
|
||||||
res: {
|
|
||||||
$switch: {
|
|
||||||
$cases: [[
|
|
||||||
{
|
|
||||||
detail: true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
emojis: EmojiDetailed[]
|
|
||||||
}
|
|
||||||
]];
|
|
||||||
$default: {
|
|
||||||
emojis: EmojiSimple[]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// api.jsonには載せないものなのでここで定義
|
// api.jsonには載せないものなのでここで定義
|
||||||
'signup': {
|
'signup': {
|
||||||
req: SignupRequest;
|
req: SignupRequest;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2024.2.0-beta.4
|
* version: 2024.2.0-beta.4
|
||||||
* generatedAt: 2024-01-23T07:38:51.367Z
|
* generatedAt: 2024-01-27T12:44:54.092Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SwitchCaseResponseType } from '../api.js';
|
import type { SwitchCaseResponseType } from '../api.js';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2024.2.0-beta.4
|
* version: 2024.2.0-beta.4
|
||||||
* generatedAt: 2024-01-23T07:38:51.365Z
|
* generatedAt: 2024-01-27T12:44:54.091Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -366,7 +366,6 @@ import type {
|
||||||
InviteLimitResponse,
|
InviteLimitResponse,
|
||||||
MetaRequest,
|
MetaRequest,
|
||||||
MetaResponse,
|
MetaResponse,
|
||||||
EmojisRequest,
|
|
||||||
EmojisResponse,
|
EmojisResponse,
|
||||||
EmojiRequest,
|
EmojiRequest,
|
||||||
EmojiResponse,
|
EmojiResponse,
|
||||||
|
@ -806,7 +805,7 @@ export type Endpoints = {
|
||||||
'invite/list': { req: InviteListRequest; res: InviteListResponse };
|
'invite/list': { req: InviteListRequest; res: InviteListResponse };
|
||||||
'invite/limit': { req: EmptyRequest; res: InviteLimitResponse };
|
'invite/limit': { req: EmptyRequest; res: InviteLimitResponse };
|
||||||
'meta': { req: MetaRequest; res: MetaResponse };
|
'meta': { req: MetaRequest; res: MetaResponse };
|
||||||
'emojis': { req: EmojisRequest; res: EmojisResponse };
|
'emojis': { req: EmptyRequest; res: EmojisResponse };
|
||||||
'emoji': { req: EmojiRequest; res: EmojiResponse };
|
'emoji': { req: EmojiRequest; res: EmojiResponse };
|
||||||
'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse };
|
'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse };
|
||||||
'mute/create': { req: MuteCreateRequest; res: EmptyResponse };
|
'mute/create': { req: MuteCreateRequest; res: EmptyResponse };
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2024.2.0-beta.4
|
* version: 2024.2.0-beta.4
|
||||||
* generatedAt: 2024-01-23T07:38:51.364Z
|
* generatedAt: 2024-01-27T12:44:54.089Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { operations } from './types.js';
|
import { operations } from './types.js';
|
||||||
|
@ -368,7 +368,6 @@ export type InviteListResponse = operations['invite/list']['responses']['200']['
|
||||||
export type InviteLimitResponse = operations['invite/limit']['responses']['200']['content']['application/json'];
|
export type InviteLimitResponse = operations['invite/limit']['responses']['200']['content']['application/json'];
|
||||||
export type MetaRequest = operations['meta']['requestBody']['content']['application/json'];
|
export type MetaRequest = operations['meta']['requestBody']['content']['application/json'];
|
||||||
export type MetaResponse = operations['meta']['responses']['200']['content']['application/json'];
|
export type MetaResponse = operations['meta']['responses']['200']['content']['application/json'];
|
||||||
export type EmojisRequest = operations['emojis']['requestBody']['content']['application/json'];
|
|
||||||
export type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
|
export type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
|
||||||
export type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
|
export type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
|
||||||
export type EmojiResponse = operations['emoji']['responses']['200']['content']['application/json'];
|
export type EmojiResponse = operations['emoji']['responses']['200']['content']['application/json'];
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* version: 2024.2.0-beta.4
|
* version: 2024.2.0-beta.4
|
||||||
* generatedAt: 2024-01-23T07:38:51.363Z
|
* generatedAt: 2024-01-27T12:44:54.088Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { components } from './types.js';
|
import { components } from './types.js';
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* version: 2024.2.0-beta.4
|
* version: 2024.2.0-beta.4
|
||||||
* generatedAt: 2024-01-23T07:38:51.282Z
|
* generatedAt: 2024-01-27T12:44:54.008Z
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -6539,16 +6539,7 @@ export type operations = {
|
||||||
/** @description OK (with results) */
|
/** @description OK (with results) */
|
||||||
200: {
|
200: {
|
||||||
content: {
|
content: {
|
||||||
'application/json': ({
|
'application/json': components['schemas']['EmojiDetailed'][];
|
||||||
/** Format: id */
|
|
||||||
id: string;
|
|
||||||
aliases: string[];
|
|
||||||
name: string;
|
|
||||||
category: string | null;
|
|
||||||
/** @description The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files. */
|
|
||||||
host: string | null;
|
|
||||||
url: string;
|
|
||||||
})[];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Client error */
|
/** @description Client error */
|
||||||
|
@ -19027,19 +19018,12 @@ export type operations = {
|
||||||
* **Credential required**: *No*
|
* **Credential required**: *No*
|
||||||
*/
|
*/
|
||||||
emojis: {
|
emojis: {
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
detail?: boolean | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK (with results) */
|
/** @description OK (with results) */
|
||||||
200: {
|
200: {
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
emojis: (components['schemas']['EmojiSimple'] | components['schemas']['EmojiDetailed'])[];
|
emojis: components['schemas']['EmojiSimple'][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue