Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot] bf41e9edd1 Bump version to 2026.1.0-alpha.2 2026-01-07 13:02:33 +00:00
syuilo f92c187e2b make __VUE_OPTIONS_API__ false (#17077) 2026-01-07 21:59:22 +09:00
syuilo 8c5572dd3b enhance(frontend): remove vuedraggable (#17073)
* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update page-editor.blocks.vue

* Update MkDraggable.vue

* refactor

* refactor

* ✌️

* refactor

* Update MkDraggable.vue

* ios

* 🎨

* 🎨
2026-01-07 21:46:03 +09:00
syuilo e18b92823f Update README.md 2026-01-07 21:43:31 +09:00
anatawa12 2d709ceeb4 fix: typo in import specifier (#17076) 2026-01-07 20:40:14 +09:00
anatawa12 38b3eecc8c migrate build scripts to esmodules (#17071)
* chore: migrate build scripts to esmodules

* chore: do not use export default in build script
2026-01-06 19:23:59 +09:00
おさむのひと f6fc78f578 refactor: DriveFileEntityServiceとDriveFolderEntityServiceの複数件取得をリファクタ (#17064)
* refactor: DriveFileEntityServiceとDriveFolderEntityServiceの複数件取得をリファクタ

* add test

* fix

* Update packages/backend/src/core/entities/DriveFolderEntityService.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/backend/test/unit/entities/DriveFolderEntityService.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/backend/src/core/entities/DriveFileEntityService.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert "Update packages/backend/src/core/entities/DriveFileEntityService.ts"

This reverts commit 83bb9564cf.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-06 13:13:06 +09:00
syuilo 6e99acf7a7 update clean scripts 2026-01-05 21:49:45 +09:00
30 changed files with 1117 additions and 215 deletions
+1
View File
@@ -13,6 +13,7 @@
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
- Enhance: ウィジェットの設定項目のラベルの多言語対応
- Enhance: パフォーマンスの向上
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正
- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正
+10
View File
@@ -51,3 +51,13 @@ Thanks to [Crowdin](https://crowdin.com/) for providing the localization platfor
<a href="https://hub.docker.com/"><img src="https://user-images.githubusercontent.com/20679825/230148221-f8e73a32-a49b-47c3-9029-9a15c3824f92.png" height="30" alt="Docker" /></a>
Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production.
---
<div align="center">
Support us with a ⭐ !
[![Star History Chart](https://api.star-history.com/svg?repos=misskey-dev/misskey&type=Date)](https://star-history.com/#misskey-dev/misskey&Date)
</div>
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.1.0-alpha.1",
"version": "2026.1.0-alpha.2",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -23,7 +23,7 @@
"private": true,
"scripts": {
"compile-config": "cd packages/backend && pnpm compile-config",
"build-pre": "node ./scripts/build-pre.js",
"build-pre": "node scripts/build-pre.mjs",
"build-assets": "node ./scripts/build-assets.mjs",
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook",
@@ -48,8 +48,8 @@
"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage",
"test": "pnpm -r test",
"test-and-coverage": "pnpm -r test-and-coverage",
"clean": "node ./scripts/clean.js",
"clean-all": "node ./scripts/clean-all.js",
"clean": "node scripts/clean.mjs",
"clean-all": "node scripts/clean-all.mjs",
"cleanall": "pnpm clean-all"
},
"resolutions": {
@@ -17,6 +17,7 @@ import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import { IdService } from '@/core/IdService.js';
import { uniqueByKey } from '@/misc/unique-by-key.js';
import { UtilityService } from '../UtilityService.js';
import { VideoProcessingService } from '../VideoProcessingService.js';
import { UserEntityService } from './UserEntityService.js';
@@ -226,6 +227,7 @@ export class DriveFileEntityService {
options?: PackOptions,
hint?: {
packedUser?: Packed<'UserLite'>
packedFolder?: Packed<'DriveFolder'>
},
): Promise<Packed<'DriveFile'> | null> {
const opts = Object.assign({
@@ -250,9 +252,9 @@ export class DriveFileEntityService {
thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment,
folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {
folder: opts.detail && file.folderId ? (hint?.packedFolder ?? this.driveFolderEntityService.pack(file.folderId, {
detail: true,
}) : null,
})) : null,
userId: file.userId,
user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null,
});
@@ -263,10 +265,41 @@ export class DriveFileEntityService {
files: MiDriveFile[],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const _user = files.map(({ user, userId }) => user ?? userId).filter(x => x != null);
const _userMap = await this.userEntityService.packMany(_user)
.then(users => new Map(users.map(user => [user.id, user])));
const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {})));
// -- ユーザ情報の事前取得 --
let userMap: Map<string, Packed<'UserLite'>> | null = null;
if (options?.withUser) {
const users = files
.map(({ user, userId }) => user ?? userId)
.filter(x => x != null);
const uniqueUsers = uniqueByKey(users, (user) => typeof user === 'string' ? user : user.id);
const packedUsers = await this.userEntityService.packMany(uniqueUsers);
userMap = new Map(packedUsers.map(user => [user.id, user]));
}
// -- フォルダ情報の事前取得 --
let folderMap: Map<string, Packed<'DriveFolder'>> | null = null;
if (options?.detail) {
const folders = files
.map(({ folder, folderId }) => folder ?? folderId)
.filter(x => x != null);
const uniqueFolders = uniqueByKey(folders, (folder) => typeof folder === 'string' ? folder : folder.id);
const packedFolders = await this.driveFolderEntityService.packMany(uniqueFolders, { detail: true });
folderMap = new Map(packedFolders.map(folder => [folder.id, folder]));
}
const items = await Promise.all(files.map(f => this.packNullable(
f,
options,
{
packedUser: f.userId ? userMap?.get(f.userId) : undefined,
packedFolder: f.folderId ? folderMap?.get(f.folderId) : undefined,
},
)));
return items.filter(x => x != null);
}
@@ -12,6 +12,9 @@ import type { } from '@/models/Blocking.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { In } from 'typeorm';
import { uniqueByKey } from '@/misc/unique-by-key.js';
import { splitIdAndObjects } from '@/misc/split-id-and-objects.js';
@Injectable()
export class DriveFolderEntityService {
@@ -32,12 +35,20 @@ export class DriveFolderEntityService {
options?: {
detail: boolean
},
hint?: {
folderMap?: Map<string, MiDriveFolder>;
foldersCountMap?: Map<string, number> | null;
filesCountMap?: Map<string, number> | null;
parentPacker?: (id: string) => Promise<Packed<'DriveFolder'>>;
},
): Promise<Packed<'DriveFolder'>> {
const opts = Object.assign({
detail: false,
}, options);
const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src });
const folder = typeof src === 'object'
? src
: hint?.folderMap?.get(src) ?? await this.driveFoldersRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: folder.id,
@@ -46,20 +57,141 @@ export class DriveFolderEntityService {
parentId: folder.parentId,
...(opts.detail ? {
foldersCount: this.driveFoldersRepository.countBy({
parentId: folder.id,
}),
filesCount: this.driveFilesRepository.countBy({
folderId: folder.id,
}),
foldersCount: hint?.foldersCountMap?.get(folder.id)
?? this.driveFoldersRepository.countBy({
parentId: folder.id,
}),
filesCount: hint?.filesCountMap?.get(folder.id)
?? this.driveFilesRepository.countBy({
folderId: folder.id,
}),
...(folder.parentId ? {
parent: this.pack(folder.parentId, {
detail: true,
}),
parent: hint?.parentPacker
? hint.parentPacker(folder.parentId)
: this.pack(folder.parentId, { detail: true }, hint),
} : {}),
} : {}),
});
}
}
public async packMany(
src: Array<MiDriveFolder['id'] | MiDriveFolder>,
options?: {
detail: boolean
},
): Promise<Array<Packed<'DriveFolder'>>> {
/**
* 重複を除去しつつ、必要なDriveFolderオブジェクトをすべて取得する
*/
const collectUniqueObjects = async (src: Array<MiDriveFolder['id'] | MiDriveFolder>) => {
const uniqueSrc = uniqueByKey(
src,
(s) => typeof s === 'string' ? s : s.id,
);
const { ids, objects } = splitIdAndObjects(uniqueSrc);
const uniqueObjects = new Map<string, MiDriveFolder>(objects.map(s => [s.id, s]));
const needsFetchIds = ids.filter(id => !uniqueObjects.has(id));
if (needsFetchIds.length > 0) {
const fetchedObjects = await this.driveFoldersRepository.find({
where: {
id: In(needsFetchIds),
},
});
for (const obj of fetchedObjects) {
uniqueObjects.set(obj.id, obj);
}
}
return uniqueObjects;
};
/**
* 親フォルダーを再帰的に収集する
*/
const collectAncestors = async (folderMap: Map<string, MiDriveFolder>) => {
for (;;) {
const parentIds = new Set<string>();
for (const folder of folderMap.values()) {
if (folder.parentId != null && !folderMap.has(folder.parentId)) {
parentIds.add(folder.parentId);
}
}
if (parentIds.size === 0) break;
const fetchedParents = await this.driveFoldersRepository.find({
where: {
id: In([...parentIds]),
},
});
if (fetchedParents.length === 0) break;
for (const parent of fetchedParents) {
folderMap.set(parent.id, parent);
}
}
};
const opts = Object.assign({
detail: false,
}, options);
const folderMap = await collectUniqueObjects(src);
let foldersCountMap: Map<string, number> | null = null;
let filesCountMap: Map<string, number> | null = null;
if (opts.detail) {
await collectAncestors(folderMap);
const ids = [...folderMap.keys()];
if (ids.length > 0) {
const folderCounts = await this.driveFoldersRepository.createQueryBuilder('folder')
.select('folder.parentId', 'parentId')
.addSelect('COUNT(*)', 'count')
.where('folder.parentId IN (:...ids)', { ids })
.groupBy('folder.parentId')
.getRawMany<{ parentId: string; count: string }>();
const fileCounts = await this.driveFilesRepository.createQueryBuilder('file')
.select('file.folderId', 'folderId')
.addSelect('COUNT(*)', 'count')
.where('file.folderId IN (:...ids)', { ids })
.groupBy('file.folderId')
.getRawMany<{ folderId: string; count: string }>();
foldersCountMap = new Map(folderCounts.map(row => [row.parentId, Number(row.count)]));
filesCountMap = new Map(fileCounts.map(row => [row.folderId, Number(row.count)]));
} else {
foldersCountMap = new Map();
filesCountMap = new Map();
}
}
const packedMap = new Map<string, Promise<Packed<'DriveFolder'>>>();
const packFromId = (id: string): Promise<Packed<'DriveFolder'>> => {
const cached = packedMap.get(id);
if (cached) return cached;
const folder = folderMap.get(id);
if (!folder) {
throw new Error(`DriveFolder not found: ${id}`);
}
const packedPromise = this.pack(folder, options, {
folderMap,
foldersCountMap,
filesCountMap,
parentPacker: packFromId,
});
packedMap.set(id, packedPromise);
return packedPromise;
};
return Promise.all(src.map(s => packFromId(typeof s === 'string' ? s : s.id)));
}
}
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* idとオブジェクトを分離する
* @param input idまたはオブジェクトの配列
* @returns idの配列とオブジェクトの配列
*/
export function splitIdAndObjects<T extends { id: string }>(input: (T | string)[]): { ids: string[]; objects: T[] } {
const ids: string[] = [];
const objects : T[] = [];
for (const item of input) {
if (typeof item === 'string') {
ids.push(item);
} else {
objects.push(item);
}
}
return {
ids,
objects,
};
}
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* itemsの中でkey関数が返す値が重複しないようにした配列を返す
* @param items 重複を除去したい配列
* @param key 重複判定に使うキーを返す関数
* @returns 重複を除去した配列
*/
export function uniqueByKey<TItem, TKey = string>(items: Iterable<TItem>, key: (item: TItem) => TKey): TItem[] {
const map = new Map<TKey, TItem>();
for (const item of items) {
const k = key(item);
if (!map.has(k)) {
map.set(k, item);
}
}
return [...map.values()];
}
@@ -0,0 +1,227 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import type { DriveFilesRepository, DriveFoldersRepository, UsersRepository } from '@/models/_.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import { genAidx } from '@/misc/id/aidx.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip;
describe('DriveFileEntityService', () => {
let app: TestingModule;
let service: DriveFileEntityService;
let driveFolderEntityService: DriveFolderEntityService;
let driveFilesRepository: DriveFilesRepository;
let driveFoldersRepository: DriveFoldersRepository;
let usersRepository: UsersRepository;
let idCounter = 0;
const userEntityServiceMock = {
packMany: jest.fn(async (users: Array<string | { id: string }>) => {
return users.map(u => ({
id: typeof u === 'string' ? u : u.id,
username: 'user',
}));
}),
pack: jest.fn(async (user: string | { id: string }) => {
return {
id: typeof user === 'string' ? user : user.id,
username: 'user',
};
}),
};
const nextId = () => genAidx(Date.now() + (idCounter++));
const createUser = async () => {
const un = secureRndstr(16);
const id = nextId();
await usersRepository.insert({
id,
username: un,
usernameLower: un.toLowerCase(),
});
return usersRepository.findOneByOrFail({ id });
};
const createFolder = async (name: string, parentId: string | null) => {
const id = nextId();
await driveFoldersRepository.insert({
id,
name,
userId: null,
parentId,
});
return driveFoldersRepository.findOneByOrFail({ id });
};
const createFile = async (folderId: string | null, userId: string | null) => {
const id = nextId();
await driveFilesRepository.insert({
id,
userId,
userHost: null,
md5: secureRndstr(32),
name: `file-${id}`,
type: 'text/plain',
size: 1,
comment: null,
blurhash: null,
properties: {},
storedInternal: true,
url: `https://example.com/${id}`,
thumbnailUrl: null,
webpublicUrl: null,
webpublicType: null,
accessKey: null,
thumbnailAccessKey: null,
webpublicAccessKey: null,
uri: null,
src: null,
folderId,
isSensitive: false,
maybeSensitive: false,
maybePorn: false,
isLink: false,
requestHeaders: null,
requestIp: null,
});
return driveFilesRepository.findOneByOrFail({ id });
};
beforeAll(async () => {
const moduleBuilder = Test.createTestingModule({
imports: [GlobalModule, CoreModule],
});
moduleBuilder.overrideProvider(UserEntityService).useValue(userEntityServiceMock as any);
app = await moduleBuilder.compile();
await app.init();
app.enableShutdownHooks();
service = app.get<DriveFileEntityService>(DriveFileEntityService);
driveFolderEntityService = app.get<DriveFolderEntityService>(DriveFolderEntityService);
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository);
});
beforeEach(() => {
userEntityServiceMock.packMany.mockClear();
userEntityServiceMock.pack.mockClear();
});
afterAll(async () => {
await app.close();
});
describe('pack', () => {
test('detail: false', async () => {
const user = await createUser();
const folder = await createFolder('pack-root', null);
const file = await createFile(folder.id, user.id);
const packed = await service.pack(file, { detail: false, self: true }) as any;
expect(packed.id).toBe(file.id);
expect(packed.folder).toBeNull();
expect(packed.user).toBeNull();
expect(packed.userId).toBeNull();
});
test('detail: true', async () => {
const folder = await createFolder('pack-parent', null);
const child = await createFolder('pack-child', folder.id);
const file = await createFile(child.id, null);
const packed = await service.pack(file, { detail: true, self: true }) as any;
expect(packed.folder?.id).toBe(child.id);
expect(packed.folder?.parent?.id).toBe(folder.id);
});
});
describe('packNullable', () => {
test('returns null for missing', async () => {
const packed = await service.packNullable('non-existent' as any, { detail: false });
expect(packed).toBeNull();
});
test('uses packedUser hint when withUser', async () => {
const user = await createUser();
const file = await createFile(null, user.id);
const packed = await service.packNullable(file, { withUser: true, self: true }, {
packedUser: { id: user.id, username: 'hint' } as any,
});
expect(packed?.user?.id).toBe(user.id);
expect(packed?.user?.username).toBe('hint');
});
});
describe('packMany', () => {
test('withUser: true uses deduped packMany', async () => {
const user = await createUser();
const fileA = await createFile(null, user.id);
const fileB = await createFile(null, user.id);
const packed = await service.packMany([fileA, fileB], { withUser: true, self: true });
expect(packed.length).toBe(2);
expect(userEntityServiceMock.packMany).toHaveBeenCalledTimes(1);
expect(userEntityServiceMock.packMany.mock.calls[0]?.[0]?.length).toBe(1);
expect(packed[0]?.user?.id).toBe(user.id);
});
test('detail: true packs folder', async () => {
const folder = await createFolder('packmany-root', null);
const file = await createFile(folder.id, null);
const packed = await service.packMany([file], { detail: true, self: true });
expect(packed[0]?.folder?.id).toBe(folder.id);
expect(packed[0]?.folder?.parent).toBeUndefined();
});
test('detail: true uses DriveFolderEntityService pack', async () => {
const folder = await createFolder('packmany-folder', null);
const file = await createFile(folder.id, null);
const packSpy = jest.spyOn(driveFolderEntityService, 'pack');
await service.packMany([file], { detail: true, self: true });
expect(packSpy).toHaveBeenCalled();
packSpy.mockRestore();
});
});
describeBenchmark('benchmark', () => {
test('packMany', async () => {
const user = await createUser();
const folders = [];
for (let i = 0; i < 100; i++) {
folders.push(await createFolder(`bench-${i}`, null));
}
const files = [];
for (const folder of folders) {
for (let j = 0; j < 20; j++) {
files.push(await createFile(folder.id, user.id));
}
}
const start = Date.now();
await service.packMany(files, { detail: true, withUser: true, self: true });
const elapsed = Date.now() - start;
console.log(`DriveFileEntityService.packMany benchmark: ${elapsed}ms`);
});
});
});
@@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { afterAll, beforeAll, describe, expect, test } from '@jest/globals';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
import { DI } from '@/di-symbols.js';
import { genAidx } from '@/misc/id/aidx.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip;
describe('DriveFolderEntityService', () => {
let app: TestingModule;
let service: DriveFolderEntityService;
let driveFoldersRepository: DriveFoldersRepository;
let driveFilesRepository: DriveFilesRepository;
let idCounter = 0;
const nextId = () => genAidx(Date.now() + (idCounter++));
const createFolder = async (name: string, parentId: string | null) => {
const id = nextId();
await driveFoldersRepository.insert({
id,
name,
userId: null,
parentId,
});
return driveFoldersRepository.findOneByOrFail({ id });
};
const createFile = async (folderId: string | null) => {
const id = nextId();
await driveFilesRepository.insert({
id,
userId: null,
userHost: null,
md5: secureRndstr(32),
name: `file-${id}`,
type: 'text/plain',
size: 1,
comment: null,
blurhash: null,
properties: {},
storedInternal: true,
url: `https://example.com/${id}`,
thumbnailUrl: null,
webpublicUrl: null,
webpublicType: null,
accessKey: null,
thumbnailAccessKey: null,
webpublicAccessKey: null,
uri: null,
src: null,
folderId,
isSensitive: false,
maybeSensitive: false,
maybePorn: false,
isLink: false,
requestHeaders: null,
requestIp: null,
});
};
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
await app.init();
app.enableShutdownHooks();
service = app.get<DriveFolderEntityService>(DriveFolderEntityService);
driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository);
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
});
afterAll(async () => {
await app.close();
});
describe('pack', () => {
test('detail: false', async () => {
const root = await createFolder('root', null);
const child = await createFolder('child', root.id);
const packed = await service.pack(child, { detail: false }) as any;
expect(packed.id).toBe(child.id);
expect(packed.parentId).toBe(root.id);
expect(packed.parent).toBeUndefined();
expect(packed.foldersCount).toBeUndefined();
expect(packed.filesCount).toBeUndefined();
});
test('detail: true', async () => {
const root = await createFolder('root-detail', null);
const child = await createFolder('child-detail', root.id);
await createFolder('grandchild-detail', child.id);
await createFile(child.id);
await createFile(child.id);
const packed = await service.pack(child, { detail: true }) as any;
expect(packed.id).toBe(child.id);
expect(packed.foldersCount).toBe(1);
expect(packed.filesCount).toBe(2);
expect(packed.parent?.id).toBe(root.id);
expect(packed.parent?.parent).toBeUndefined();
});
test('detail: true reaches root for deep hierarchy', async () => {
const root = await createFolder('root-deep', null);
const level1 = await createFolder('level-1', root.id);
const level2 = await createFolder('level-2', level1.id);
const level3 = await createFolder('level-3', level2.id);
const level4 = await createFolder('level-4', level3.id);
const level5 = await createFolder('level-5', level4.id);
const packed = await service.pack(level5, { detail: true }) as any;
expect(packed.id).toBe(level5.id);
expect(packed.parent?.id).toBe(level4.id);
expect(packed.parent?.parent?.id).toBe(level3.id);
expect(packed.parent?.parent?.parent?.id).toBe(level2.id);
expect(packed.parent?.parent?.parent?.parent?.id).toBe(level1.id);
expect(packed.parent?.parent?.parent?.parent?.parent?.id).toBe(root.id);
expect(packed.parent?.parent?.parent?.parent?.parent?.parent).toBeUndefined();
});
});
describe('packMany', () => {
test('preserves order and packs parents', async () => {
const root = await createFolder('root-many', null);
const childA = await createFolder('child-a', root.id);
const childB = await createFolder('child-b', root.id);
await createFolder('child-a-sub', childA.id);
await createFile(childA.id);
const packed = await service.packMany([childB, childA], { detail: true }) as any;
expect(packed[0].id).toBe(childB.id);
expect(packed[1].id).toBe(childA.id);
expect(packed[0].parent?.id).toBe(root.id);
expect(packed[1].parent?.id).toBe(root.id);
expect(packed[0].filesCount).toBe(0);
expect(packed[1].filesCount).toBe(1);
expect(packed[0].foldersCount).toBe(0);
expect(packed[1].foldersCount).toBe(1);
});
});
describeBenchmark('benchmark', () => {
test('packMany', async () => {
const root = await createFolder('bench-root', null);
const folders = [];
for (let i = 0; i < 200; i++) {
folders.push(await createFolder(`bench-${i}`, root.id));
}
const start = Date.now();
await service.packMany(folders, { detail: true });
const elapsed = Date.now() - start;
console.log(`DriveFolderEntityService.packMany benchmark: ${elapsed}ms`);
});
});
});
-1
View File
@@ -75,7 +75,6 @@
"v-code-diff": "1.13.1",
"vite": "7.3.0",
"vue": "3.5.26",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
"devDependencies": {
@@ -0,0 +1,310 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<TransitionGroup
tag="div"
:enterActiveClass="$style.transition_items_enterActive"
:leaveActiveClass="$style.transition_items_leaveActive"
:enterFromClass="$style.transition_items_enterFrom"
:leaveToClass="$style.transition_items_leaveTo"
:moveClass="$style.transition_items_move"
:class="[$style.items, { [$style.dragging]: dragging, [$style.horizontal]: direction === 'horizontal', [$style.vertical]: direction === 'vertical', [$style.withGaps]: withGaps, [$style.canNest]: canNest }]"
>
<slot name="header"></slot>
<div
v-if="modelValue.length === 0"
:class="$style.emptyDropArea"
@dragover.prevent.stop="() => {}"
@dragleave="() => {}"
@drop.prevent.stop="onEmptyDrop($event)"
>
</div>
<div
v-for="(item, i) in modelValue"
:key="item.id"
:class="$style.item"
:draggable="!manualDragStart"
@dragstart.stop="onDragstart($event, item)"
>
<div
:class="[$style.forwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'forward' }]"
@dragover.prevent.stop="onDragover($event, item, false)"
@dragleave="onDragleave($event, item)"
@drop.prevent.stop="onDrop($event, item, false)"
></div>
<div style="position: relative; z-index: 0;">
<slot :item="item" :index="i" :dragStart="(ev) => onDragstart(ev, item)"></slot>
</div>
<div
:class="[$style.backwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'backward' }]"
@dragover.prevent.stop="onDragover($event, item, true)"
@dragleave="onDragleave($event, item)"
@drop.prevent.stop="onDrop($event, item, true)"
></div>
</div>
<slot name="footer"></slot>
</TransitionGroup>
</template>
<script lang="ts">
import { ref } from 'vue';
// 別々のコンポーネントインスタンス間でD&Dを融通するためにグローバルに状態を持っておく必要がある
const dragging = ref(false);
let dropCallback: ((targetInstanceId: string) => void) | null = null;
</script>
<script lang="ts" setup generic="T extends { id: string; }">
import { nextTick } from 'vue';
import { getDragData, setDragData } from '@/drag-and-drop.js';
import { genId } from '@/utility/id.js';
const slots = defineSlots<{
default(props: { item: T; index: number; dragStart: (ev: DragEvent) => void }): any;
header(): any;
footer(): any;
}>();
const props = withDefaults(defineProps<{
modelValue: T[];
direction: 'horizontal' | 'vertical';
group?: string | null;
manualDragStart?: boolean;
withGaps?: boolean;
canNest?: boolean;
}>(), {
group: null,
manualDragStart: false,
withGaps: false,
canNest: false,
});
const emit = defineEmits<{
(ev: 'update:modelValue', value: T[]): void;
}>();
const dropReadyArea = ref<[T['id'] | null, 'forward' | 'backward' | null]>([null, null]);
const instanceId = genId();
const group = props.group ?? instanceId;
function onDragstart(ev: DragEvent, item: T) {
if (ev.dataTransfer == null) return;
ev.dataTransfer.effectAllowed = 'move';
setDragData(ev, 'MkDraggable', { item, instanceId, group });
const target = ev.target as HTMLElement;
target.addEventListener('dragend', (ev) => {
dragging.value = false;
dropReadyArea.value = [null, null];
}, { once: true });
dropCallback = (targetInstanceId) => {
if (targetInstanceId === instanceId) return;
const newValue = props.modelValue.filter(x => x.id !== item.id);
emit('update:modelValue', newValue);
};
// Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう
// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
window.setTimeout(() => {
dragging.value = true;
}, 10);
}
function onDragover(ev: DragEvent, item: T, backward: boolean) {
nextTick(() => {
dropReadyArea.value = [item.id, backward ? 'backward' : 'forward'];
});
}
function onDragleave(ev: DragEvent, item: T) {
dropReadyArea.value = [null, null];
}
function onDrop(ev: DragEvent, item: T, backward: boolean) {
const dragged = getDragData(ev, 'MkDraggable');
dropReadyArea.value = [null, null];
if (dragged == null || dragged.group !== group || dragged.item.id === item.id) return;
dropCallback?.(instanceId);
const fromIndex = props.modelValue.findIndex(x => x.id === dragged.item.id);
let toIndex = props.modelValue.findIndex(x => x.id === item.id);
const newValue = [...props.modelValue];
if (fromIndex > -1) newValue.splice(fromIndex, 1);
toIndex = newValue.findIndex(x => x.id === item.id);
if (backward) toIndex += 1;
newValue.splice(toIndex, 0, dragged.item as T);
emit('update:modelValue', newValue);
}
function onEmptyDrop(ev: DragEvent) {
const dragged = getDragData(ev, 'MkDraggable');
if (dragged == null) return;
dropCallback?.(instanceId);
emit('update:modelValue', [dragged.item as T]);
}
</script>
<style lang="scss" module>
.transition_items_move,
.transition_items_enterActive,
.transition_items_leaveActive {
transition: all 0.15s ease;
}
.transition_items_enterFrom,
.transition_items_leaveTo {
opacity: 0;
}
.transition_items_leaveActive {
position: absolute;
}
.items {
display: flex;
align-items: center;
justify-content: left;
flex-wrap: wrap;
}
.items.horizontal {
flex-direction: row;
}
.items.vertical {
flex-direction: column;
}
.item {
position: relative;
}
.items.vertical .item {
width: 100%;
}
.items.horizontal.withGaps {
row-gap: var(--MI-margin);
}
.items.horizontal.withGaps .item {
padding-left: calc(var(--MI-margin) / 2);
padding-right: calc(var(--MI-margin) / 2);
}
.items.vertical.withGaps .item {
padding-top: calc(var(--MI-margin) / 2);
padding-bottom: calc(var(--MI-margin) / 2);
}
.forwardArea, .backwardArea {
position: absolute;
z-index: 1;
pointer-events: none;
}
.items.dragging {
.forwardArea, .backwardArea {
pointer-events: auto;
}
}
.items.horizontal {
.forwardArea {
top: 0;
left: 0;
width: 50%;
height: 100%;
}
.backwardArea {
top: 0;
right: 0;
width: 50%;
height: 100%;
}
}
.items.vertical {
.forwardArea {
top: 0;
left: 0;
width: 100%;
height: 50%;
}
.backwardArea {
bottom: 0;
left: 0;
width: 100%;
height: 50%;
}
}
.items.canNest.horizontal {
.forwardArea, .backwardArea {
width: 30px;
}
}
.items.canNest.vertical {
.forwardArea, .backwardArea {
height: 30px;
}
}
.dropReady::before {
content: '';
position: absolute;
z-index: 99999;
background: var(--MI_THEME-accent);
border-radius: 999px;
pointer-events: none;
}
.items.horizontal {
.forwardArea.dropReady::before {
top: 0;
left: -1px;
width: 2px;
height: 100%;
}
.backwardArea.dropReady::before {
top: 0;
right: -1px;
width: 2px;
height: 100%;
}
}
.items.vertical {
.forwardArea.dropReady::before {
top: -1px;
left: 0;
width: 100%;
height: 2px;
}
.backwardArea.dropReady::before {
bottom: -1px;
left: 0;
width: 100%;
height: 2px;
}
}
.items.horizontal .emptyDropArea {
width: 40px;
height: 40px;
}
.items.vertical .emptyDropArea {
width: 100%;
height: 50px;
}
</style>
@@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-show="props.modelValue.length != 0" :class="$style.root">
<Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)">
<template #item="{ element }">
<MkDraggable
:modelValue="props.modelValue"
:class="$style.files"
direction="horizontal"
withGaps
@update:modelValue="v => emit('update:modelValue', v)"
>
<template #default="{ item }">
<div
:class="$style.file"
role="button"
tabindex="0"
@click="showFileMenu(element, $event)"
@keydown.space.enter="showFileMenu(element, $event)"
@contextmenu.prevent="showFileMenu(element, $event)"
@click="showFileMenu(item, $event)"
@keydown.space.enter="showFileMenu(item, $event)"
@contextmenu.prevent="showFileMenu(item, $event)"
>
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
<div v-if="element.isSensitive" :class="$style.sensitive">
<!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる -->
<MkDriveFileThumbnail style="pointer-events: none;" :data-id="item.id" :class="$style.thumbnail" :file="item" fit="cover"/>
<div v-if="item.isSensitive" :class="$style.sensitive" style="pointer-events: none;">
<i class="ti ti-eye-exclamation" style="margin: auto;"></i>
</div>
</div>
</template>
</Sortable>
</MkDraggable>
<p
:class="[$style.remain, {
[$style.exceeded]: props.modelValue.length > 16,
@@ -33,11 +40,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { defineAsyncComponent, inject } from 'vue';
import { inject } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
@@ -45,8 +53,6 @@ import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const props = defineProps<{
modelValue: Misskey.entities.DriveFile[];
detachMediaFn?: (id: string) => void;
@@ -221,7 +227,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
position: relative;
width: 64px;
height: 64px;
margin-right: 4px;
border-radius: 4px;
overflow: hidden;
cursor: move;
+12 -26
View File
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.root" class="_gaps_s">
<template v-if="edit">
<header :class="$style.editHeader">
<MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
@@ -13,25 +13,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton>
</header>
<Sortable
<MkDraggable
:modelValue="props.widgets"
itemKey="id"
handle=".handle"
:animation="150"
:group="{ name: 'SortableMkWidgets' }"
:class="$style.editEditing"
direction="vertical"
withGaps
group="MkWidgets"
@update:modelValue="v => emit('updateWidgets', v)"
>
<template #item="{element}">
<template #default="{ item }">
<div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container>
<button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button>
<button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button>
<div class="handle">
<component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style.customizeContainerHandleWidget" :widget="element" @updateProps="updateWidget(element.id, $event)"/>
</div>
<button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(item.id)"><i class="ti ti-settings"></i></button>
<button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(item)"><i class="ti ti-x"></i></button>
<component :is="`widget-${item.name}`" :ref="el => widgetRefs[item.id] = el" :class="$style.customizeContainerHandleWidget" :widget="item" @updateProps="updateWidget(item.id, $event)"/>
</div>
</template>
</Sortable>
</MkDraggable>
</template>
<component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
</div>
@@ -49,19 +45,18 @@ export type DefaultStoredWidget = {
</script>
<script lang="ts" setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { computed } from 'vue';
import { isLink } from '@@/js/is-link.js';
import { genId } from '@/utility/id.js';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const props = defineProps<{
widgets: Widget[];
edit: boolean;
@@ -142,11 +137,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
.widget {
contain: content;
margin: var(--MI-margin) 0;
&:first-of-type {
margin-top: 0;
}
}
.edit {
@@ -158,10 +148,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) {
padding: 4px;
}
}
&Editing {
min-height: 100px;
}
}
.customizeContainer {
+1
View File
@@ -9,6 +9,7 @@ type DragDataMap = {
driveFiles: Misskey.entities.DriveFile[];
driveFolders: Misskey.entities.DriveFolder[];
deckColumn: string;
MkDraggable: { item: { id: string }; instanceId: string; group: string; };
};
// NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.header">
<MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect">
</MkSelect>
<button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle">
<button v-if="draggable" class="_button" :class="$style.dragHandle" :draggable="true" @dragstart.stop="dragStartCallback">
<i class="ti ti-menu-2"></i>
</button>
<button v-if="draggable" class="_button" :class="$style.remove" @click="removeSelf">
@@ -17,14 +17,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="type === 'and' || type === 'or'" class="_gaps">
<Sortable v-model="v.values" tag="div" class="_gaps" itemKey="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swapThreshold="0.5">
<template #item="{element}">
<MkDraggable
v-model="v.values"
direction="vertical"
withGaps
canNest
manualDragStart
group="roleFormula"
>
<template #default="{ item, dragStart }">
<div :class="$style.item">
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
<RolesEditorFormula :modelValue="element" draggable @update:modelValue="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/>
<RolesEditorFormula
:modelValue="item"
:dragStartCallback="dragStart"
draggable
@update:modelValue="updated => valuesItemUpdated(updated)"
@remove="removeItem(item.id)"
/>
</div>
</template>
</Sortable>
</MkDraggable>
<MkButton rounded style="margin: 0 auto;" @click="addValue"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
</div>
@@ -45,18 +58,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue';
import { genId } from '@/utility/id.js';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js';
import { rolesCache } from '@/cache.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const emit = defineEmits<{
(ev: 'update:modelValue', value: any): void;
(ev: 'remove'): void;
@@ -65,6 +77,7 @@ const emit = defineEmits<{
const props = defineProps<{
modelValue: any;
draggable?: boolean;
dragStartCallback?: (ev: DragEvent) => void;
}>();
const v = ref(deepClone(props.modelValue));
@@ -132,8 +145,8 @@ function valuesItemUpdated(item) {
v.value.values[i] = item;
}
function removeItem(item) {
v.value.values = v.value.values.filter(_item => _item.id !== item.id);
function removeItem(itemId) {
v.value.values = v.value.values.filter(_item => _item.id !== itemId);
}
function removeSelf() {
@@ -12,28 +12,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m">
<div><SearchText>{{ i18n.ts._serverRules.description }}</SearchText></div>
<Sortable
<MkDraggable
v-model="serverRules"
class="_gaps_m"
:itemKey="(_, i) => i"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
@end="e => e.item.classList.remove('active')"
direction="vertical"
withGaps
manualDragStart
>
<template #item="{element,index}">
<template #default="{ item, index, dragStart }">
<div :class="$style.item">
<div :class="$style.itemHeader">
<div :class="$style.itemNumber" v-text="String(index + 1)"/>
<span :class="$style.itemHandle"><i class="ti ti-menu"/></span>
<button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button>
<span :class="$style.itemHandle" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"/></span>
<button class="_button" :class="$style.itemRemove" @click="remove(item.id)"><i class="ti ti-x"></i></button>
</div>
<MkInput v-model="serverRules[index]"/>
<MkInput :modelValue="item.text" @update:modelValue="serverRules[index].text = $event"/>
</div>
</template>
</Sortable>
</MkDraggable>
<div :class="$style.commands">
<MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton rounded @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
@@ -42,28 +39,31 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { ref } from 'vue';
import * as os from '@/os.js';
import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkDraggable from '@/components/MkDraggable.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const serverRules = ref<{ text: string; id: string; }[]>(instance.serverRules.map(text => ({ text, id: Math.random().toString() })));
const serverRules = ref<string[]>(instance.serverRules);
const save = async () => {
async function save() {
await os.apiWithDialog('admin/update-meta', {
serverRules: serverRules.value,
serverRules: serverRules.value.map(r => r.text),
});
fetchInstance(true);
};
}
const remove = (index: number): void => {
serverRules.value.splice(index, 1);
};
function add(): void {
serverRules.value.push({ text: '', id: Math.random().toString() });
}
function remove(id: string): void {
serverRules.value = serverRules.value.filter(r => r.id !== id);
}
</script>
<style lang="scss" module>
+16 -22
View File
@@ -41,20 +41,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton>
<Sortable
v-model="pinnedNotes"
itemKey="id"
:handle="'.' + $style.pinnedNoteHandle"
:animation="150"
<MkDraggable
:modelValue="pinnedNoteIds.map(id => ({ id }))"
direction="vertical"
@update:modelValue="v => pinnedNoteIds = v.map(x => x.id)"
>
<template #item="{element,index}">
<template #default="{ item }">
<div :class="$style.pinnedNote">
<button class="_button" :class="$style.pinnedNoteHandle"><i class="ti ti-menu"></i></button>
{{ element.id }}
<button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(index)"><i class="ti ti-x"></i></button>
{{ item.id }}
<button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(item.id)"><i class="ti ti-x"></i></button>
</div>
</template>
</Sortable>
</MkDraggable>
</div>
</MkFolder>
@@ -68,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ref, watch, defineAsyncComponent } from 'vue';
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@@ -81,10 +80,9 @@ import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import { useRouter } from '@/router.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const router = useRouter();
const props = defineProps<{
@@ -99,7 +97,7 @@ const bannerId = ref<string | null>(null);
const color = ref('#000');
const isSensitive = ref(false);
const allowRenoteToExternal = ref(true);
const pinnedNotes = ref<{ id: Misskey.entities.Note['id'] }[]>([]);
const pinnedNoteIds = ref<Misskey.entities.Note['id'][]>([]);
watch(() => bannerId.value, async () => {
if (bannerId.value == null) {
@@ -123,9 +121,7 @@ async function fetchChannel() {
bannerId.value = result.bannerId;
bannerUrl.value = result.bannerUrl;
isSensitive.value = result.isSensitive;
pinnedNotes.value = result.pinnedNoteIds.map(id => ({
id,
}));
pinnedNoteIds.value = result.pinnedNoteIds;
color.value = result.color;
allowRenoteToExternal.value = result.allowRenoteToExternal;
@@ -143,13 +139,11 @@ async function addPinnedNote() {
const note = await os.apiWithDialog('notes/show', {
noteId: fromUrl ?? value,
});
pinnedNotes.value = [{
id: note.id,
}, ...pinnedNotes.value];
pinnedNoteIds.value.unshift(note.id);
}
function removePinnedNote(index: number) {
pinnedNotes.value.splice(index, 1);
function removePinnedNote(id: string) {
pinnedNoteIds.value = pinnedNoteIds.value.filter(x => x !== id);
}
function save() {
@@ -166,7 +160,7 @@ function save() {
os.apiWithDialog('channels/update', {
...params,
channelId: props.channelId,
pinnedNoteIds: pinnedNotes.value.map(x => x.id),
pinnedNoteIds: pinnedNoteIds.value,
});
} else {
os.apiWithDialog('channels/create', params).then(created => {
@@ -4,36 +4,41 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)">
<template #item="{element}">
<div :class="$style.item">
<MkDraggable
:modelValue="modelValue"
direction="vertical"
withGaps
canNest
group="pageBlocks"
@update:modelValue="v => emit('update:modelValue', v)"
>
<template #default="{ item }">
<div>
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
<component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/>
<component :is="getComponent(item.type) as any" :modelValue="item" @update:modelValue="updateItem" @remove="() => removeItem(item)"/>
</div>
</template>
</Sortable>
</MkDraggable>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import XSection from './els/page-editor.el.section.vue';
import XText from './els/page-editor.el.text.vue';
import XImage from './els/page-editor.el.image.vue';
import XNote from './els/page-editor.el.note.vue';
import MkDraggable from '@/components/MkDraggable.vue';
function getComponent(type: string) {
function getComponent(type: Misskey.entities.Page['content'][number]['type']) {
switch (type) {
case 'section': return XSection;
case 'text': return XText;
case 'image': return XImage;
case 'note': return XNote;
default: return null;
default: return XText;
}
}
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const props = defineProps<{
modelValue: Misskey.entities.Page['content'];
}>();
@@ -61,11 +66,3 @@ function removeItem(el) {
emit('update:modelValue', newValue);
}
</script>
<style lang="scss" module>
.item {
& + .item {
margin-top: 16px;
}
}
</style>
@@ -18,19 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="emojis"
<MkDraggable
:modelValue="emojis.map(emoji => ({ id: emoji, emoji }))"
direction="horizontal"
:class="$style.emojis"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
:group="{ name: 'SortableEmojiPalettes' }"
group="emojiPalettes"
@update:modelValue="v => emojis = v.map(x => x.emoji)"
>
<template #item="{element}">
<button class="_button" :class="$style.emojisItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
<template #default="{ item }">
<button class="_button" :class="$style.emojisItem" @click="remove(item.emoji, $event)">
<!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる -->
<MkCustomEmoji v-if="item.emoji[0] === ':'" style="pointer-events: none;" :name="item.emoji" :normal="true" :fallbackToImage="true"/>
<MkEmoji v-else style="pointer-events: none;" :emoji="item.emoji" :normal="true"/>
</button>
</template>
<template #footer>
@@ -38,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</MkDraggable>
</div>
<div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div>
</div>
@@ -47,7 +46,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
@@ -55,6 +53,7 @@ import { deepClone } from '@/utility/clone.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
const props = defineProps<{
+11 -16
View File
@@ -9,25 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSlot>
<template #label>{{ i18n.ts.navbar }}</template>
<MkContainer :showHeader="false">
<Sortable
<MkDraggable
v-model="items"
itemKey="id"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
@end="e => e.item.classList.remove('active')"
direction="vertical"
>
<template #item="{element,index}">
<template #default="{ item }">
<div
v-if="element.type === '-' || navbarItemDef[element.type]"
v-if="item.type === '-' || navbarItemDef[item.type]"
:class="$style.item"
>
<button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button>
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span>
<button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button>
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item.type]?.title ?? i18n.ts.divider }}</span>
<button class="_button" :class="$style.itemRemove" @click="removeItem(item.id)"><i class="ti ti-x"></i></button>
</div>
</template>
</Sortable>
</MkDraggable>
</MkContainer>
</FormSlot>
<div class="_buttons">
@@ -54,13 +50,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSlot from '@/components/form/slot.vue';
import MkContainer from '@/components/MkContainer.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { store } from '@/store.js';
@@ -70,8 +67,6 @@ import { prefer } from '@/preferences.js';
import { getInitialPrefValue } from '@/preferences/manager.js';
import { genId } from '@/utility/id.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const items = ref(prefer.s.menu.map(x => ({
id: genId(),
type: x,
@@ -98,8 +93,8 @@ async function addItem() {
}];
}
function removeItem(index: number) {
items.value.splice(index, 1);
function removeItem(itemId: string) {
items.value = items.value.filter(i => i.id !== itemId);
}
function save() {
@@ -75,30 +75,27 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.metadataRoot" class="_gaps_s">
<MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo>
<Sortable
<MkDraggable
v-model="fields"
class="_gaps_s"
itemKey="id"
:animation="150"
:handle="'.' + $style.dragItemHandle"
@start="e => e.item.classList.add('active')"
@end="e => e.item.classList.remove('active')"
direction="vertical"
withGaps
manualDragStart
>
<template #item="{element, index}">
<template #default="{ item, dragStart }">
<div v-panel :class="$style.fieldDragItem">
<button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button>
<button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button>
<button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"></i></button>
<button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(item.id)"><i class="ti ti-x"></i></button>
<div :class="$style.dragItemForm">
<FormSplit :minWidth="200">
<MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel">
<MkInput v-model="item.name" small :placeholder="i18n.ts._profile.metadataLabel">
</MkInput>
<MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent">
<MkInput v-model="item.value" small :placeholder="i18n.ts._profile.metadataContent">
</MkInput>
</FormSplit>
</div>
</div>
</template>
</Sortable>
</MkDraggable>
</div>
</MkFolder>
<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
@@ -165,7 +162,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
@@ -174,6 +171,7 @@ import FormSplit from '@/components/form/split.vue';
import MkFolder from '@/components/MkFolder.vue';
import FormSlot from '@/components/form/slot.vue';
import FormLink from '@/components/form/link.vue';
import MkDraggable from '@/components/MkDraggable.vue';
import { chooseDriveFile } from '@/utility/drive.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
@@ -188,8 +186,6 @@ import { genId } from '@/utility/id.js';
const $i = ensureSignin();
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const reactionAcceptance = store.model('reactionAcceptance');
function assertVaildLang(lang: string | null): lang is keyof typeof langmap {
@@ -228,8 +224,8 @@ while (fields.value.length < 4) {
addField();
}
function deleteField(index: number) {
fields.value.splice(index, 1);
function deleteField(itemId: string) {
fields.value = fields.value.filter(f => f.id !== itemId);
}
function saveFields() {
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XWidgets :edit="editMode" :widgets="widgets" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="editMode = false"/>
<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
<button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
<button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em; margin-top: 16px;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
</div>
</template>
+1 -1
View File
@@ -167,7 +167,7 @@ export function getConfig(): UserConfig {
_ENV_: JSON.stringify(process.env.NODE_ENV),
_DEV_: process.env.NODE_ENV !== 'production',
_PERF_PREFIX_: JSON.stringify('Misskey:'),
__VUE_OPTIONS_API__: true,
__VUE_OPTIONS_API__: false,
__VUE_PROD_DEVTOOLS__: false,
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.1.0-alpha.1",
"version": "2026.1.0-alpha.2",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
+1 -25
View File
@@ -833,9 +833,6 @@ importers:
vue:
specifier: 3.5.26
version: 3.5.26(typescript@5.9.3)
vuedraggable:
specifier: next
version: 4.1.0(vue@3.5.26(typescript@5.9.3))
wanakana:
specifier: 5.3.1
version: 5.3.1
@@ -4933,10 +4930,6 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.49.0':
resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.50.1':
resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -10107,9 +10100,6 @@ packages:
resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==}
engines: {node: '>=0.10.0'}
sortablejs@1.14.0:
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -11114,11 +11104,6 @@ packages:
typescript:
optional: true
vuedraggable@4.1.0:
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
peerDependencies:
vue: ^3.0.1
w3c-xmlserializer@4.0.0:
resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
engines: {node: '>=14'}
@@ -15278,7 +15263,7 @@ snapshots:
'@stylistic/eslint-plugin@5.5.0(eslint@9.39.2)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2)
'@typescript-eslint/types': 8.49.0
'@typescript-eslint/types': 8.50.1
eslint: 9.39.2
eslint-visitor-keys: 4.2.1
espree: 10.4.0
@@ -15972,8 +15957,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.49.0': {}
'@typescript-eslint/types@8.50.1': {}
'@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)':
@@ -22285,8 +22268,6 @@ snapshots:
dependencies:
is-plain-obj: 1.1.0
sortablejs@1.14.0: {}
source-map-js@1.2.1: {}
source-map-support@0.5.13:
@@ -23270,11 +23251,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vuedraggable@4.1.0(vue@3.5.26(typescript@5.9.3)):
dependencies:
sortablejs: 1.14.0
vue: 3.5.26(typescript@5.9.3)
w3c-xmlserializer@4.0.0:
dependencies:
xml-name-validator: 4.0.0
+1 -1
View File
@@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as yaml from 'js-yaml';
import buildTarball from './tarball.mjs';
import { buildTarball } from './tarball.mjs';
const configDir = fileURLToPath(new URL('../.config', import.meta.url));
const configPath = process.env.MISSKEY_CONFIG_YML
@@ -3,7 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
const fs = require('fs');
import * as fs from 'node:fs';
const __dirname = import.meta.dirname;
const packageJsonPath = __dirname + '/../package.json'
function build() {
@@ -3,11 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
const { execSync } = require('child_process');
const fs = require('fs');
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';
const __dirname = import.meta.dirname;
(async () => {
fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/backend/src-js', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/backend/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
+4 -1
View File
@@ -3,10 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
const fs = require('fs');
import * as fs from 'node:fs';
const __dirname = import.meta.dirname;
(async () => {
fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/backend/src-js', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true });
+1 -1
View File
@@ -19,7 +19,7 @@ const ignore = [
// Exclude files you don't want to include in the tarball here
];
export default async function build() {
export async function buildTarball() {
const mkdirPromise = mkdir(resolve(cwd, 'built', 'tarball'), { recursive: true });
const pack = new Pack({ cwd, gzip: true });
const patterns = await walk({ path: cwd, ignoreFiles: ['.gitignore'] });