1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-06-12 07:03:59 +02:00

Merge branch 'develop' into mahjong

This commit is contained in:
syuilo
2026-01-22 11:47:13 +09:00
1550 changed files with 116596 additions and 65108 deletions

View File

@@ -1 +1,2 @@
/storybook-static
/build/

View File

@@ -55,7 +55,7 @@ await fs.readFile(
'../../locales/ja-JP.yml',
'assets/**',
'public/**',
'../../pnpm-lock.yaml',
'package.json',
]).length
) {
return;

View File

@@ -6,7 +6,7 @@
import { HttpResponse, http } from 'msw';
import type { DefaultBodyType, HttpResponseResolver, JsonBodyType, PathParams } from 'msw';
import seedrandom from 'seedrandom';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
const rng = seedrandom(seed);
@@ -33,7 +33,7 @@ export function getChartResolver(fields: string[], option?: { accumulate?: boole
const res = {};
for (const field of fields) {
const layers = field.split('.');
let current = res;
let current = res as any;
while (layers.length > 1) {
const currentKey = layers.shift()!;
if (current[currentKey] == null) current[currentKey] = {};

View File

@@ -33,6 +33,7 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
description: null,
userId: null,
bannerUrl,
bannerId: null,
pinnedNoteIds: [],
color: '#000',
isArchived: false,
@@ -127,7 +128,7 @@ export function galleryPost(isSensitive = false) {
}
}
export function file(isSensitive = false) {
export function file(isSensitive = false): entities.DriveFile {
return {
id: 'somefileid',
createdAt: '2016-12-28T22:49:51.000Z',
@@ -207,6 +208,7 @@ export function federationInstance(): entities.FederationInstance {
isSuspended: false,
suspensionState: 'none',
isBlocked: false,
isMediaSilenced: false,
softwareName: 'misskey',
softwareVersion: '2024.5.0',
openRegistrations: false,
@@ -311,6 +313,8 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host: enti
alsoKnownAs: null,
notify: 'none',
memo: null,
canChat: true,
chatScope: 'everyone',
};
}
@@ -378,6 +382,7 @@ export function role(params: {
asBadge: params.asBadge ?? true,
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
usersCount: params.usersCount ?? 10,
preserveAssignmentOnMoveAccount: false,
condFormula: {
id: '',
type: 'or',

View File

@@ -3,12 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, globSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { basename, dirname } from 'node:path/posix';
import { GENERATOR, type State, generate } from 'astring';
import type * as estree from 'estree';
import glob from 'fast-glob';
import { format } from 'prettier';
interface SatisfiesExpression extends estree.BaseExpression {
@@ -439,38 +438,37 @@ function toStories(component: string): Promise<string> {
// glob('src/{components,pages,ui,widgets}/**/*.vue')
(async () => {
const globs = await Promise.all([
glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/MkAbuseReportWindow.vue'),
glob('src/components/MkAccountMoved.vue'),
glob('src/components/MkAchievements.vue'),
glob('src/components/MkAnalogClock.vue'),
glob('src/components/MkAnimBg.vue'),
glob('src/components/MkAnnouncementDialog.vue'),
glob('src/components/MkAntennaEditor.vue'),
glob('src/components/MkAntennaEditorDialog.vue'),
glob('src/components/MkAsUi.vue'),
glob('src/components/MkAutocomplete.vue'),
glob('src/components/MkAvatars.vue'),
glob('src/components/Mk[B-E]*.vue'),
glob('src/components/MkFlashPreview.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/components/MkImgPreviewDialog.vue'),
glob('src/components/MkInstanceCardMini.vue'),
glob('src/components/MkInviteCode.vue'),
glob('src/components/MkTagItem.vue'),
glob('src/components/MkRoleSelectDialog.vue'),
glob('src/components/grid/MkGrid.vue'),
glob('src/pages/admin/custom-emojis-manager2.vue'),
glob('src/pages/admin/overview.ap-requests.vue'),
glob('src/pages/user/home.vue'),
glob('src/pages/search.vue'),
]);
const components = globs.flat();
const components = [
globSync('src/components/global/Mk*.vue'),
globSync('src/components/global/RouterView.vue'),
globSync('src/components/MkAbuseReportWindow.vue'),
globSync('src/components/MkAccountMoved.vue'),
globSync('src/components/MkAchievements.vue'),
globSync('src/components/MkAnalogClock.vue'),
globSync('src/components/MkAnimBg.vue'),
globSync('src/components/MkAnnouncementDialog.vue'),
globSync('src/components/MkAntennaEditor.vue'),
globSync('src/components/MkAntennaEditorDialog.vue'),
globSync('src/components/MkAsUi.vue'),
globSync('src/components/MkAutocomplete.vue'),
globSync('src/components/MkAvatars.vue'),
globSync('src/components/Mk[B-E]*.vue'),
globSync('src/components/MkFlashPreview.vue'),
globSync('src/components/MkGalleryPostPreview.vue'),
globSync('src/components/MkSignupServerRules.vue'),
globSync('src/components/MkUserSetupDialog.vue'),
globSync('src/components/MkUserSetupDialog.*.vue'),
globSync('src/components/MkImgPreviewDialog.vue'),
globSync('src/components/MkInstanceCardMini.vue'),
globSync('src/components/MkInviteCode.vue'),
globSync('src/components/MkTagItem.vue'),
globSync('src/components/MkRoleSelectDialog.vue'),
globSync('src/components/grid/MkGrid.vue'),
globSync('src/pages/admin/custom-emojis-manager2.vue'),
globSync('src/pages/admin/overview.ap-requests.vue'),
globSync('src/pages/user/home.vue'),
globSync('src/pages/search.vue'),
].flat();
await Promise.all(components.map(async (component) => {
const stories = component.replace(/\.vue$/, '.stories.ts');
await writeFile(stories, await toStories(component));

View File

@@ -34,7 +34,7 @@ export const commonHandlers = [
}),
http.get('/twemoji/:codepoints.svg', async ({ params }) => {
const { codepoints } = params;
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@15.0.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@16.0.1/dist/svg/${codepoints}.svg`).then((response) => response.blob());
return new HttpResponse(value, {
headers: {
'Content-Type': 'image/svg+xml',

View File

@@ -4,7 +4,7 @@
*/
import { writeFile } from 'node:fs/promises';
import locales from '../../../locales/index.js';
import locales from 'i18n';
await writeFile(
new URL('locale.ts', import.meta.url),

View File

@@ -9,7 +9,6 @@ import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { userDetailed } from './fakes.js';
import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks.js';
import themes from './themes.js';
import '../src/style.scss';
@@ -55,7 +54,6 @@ function initLocalStorage() {
...userDetailed(),
policies: {},
}));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({

View File

@@ -42,7 +42,7 @@
"prefix": "storyimplevent",
"body": [
"/* eslint-disable @typescript-eslint/explicit-function-return-type */",
"import { action } from '@storybook/addon-actions';",
"import { action } from 'storybook/actions';",
"import { StoryObj } from '@storybook/vue3';",
"import $1 from './$1.vue';",
"export const Default = {",

View File

@@ -10,9 +10,6 @@ declare const _VERSION_: string;
declare const _ENV_: string;
declare const _DEV_: boolean;
declare const _PERF_PREFIX_: string;
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,51 @@
import * as fs from 'fs/promises';
import url from 'node:url';
import path from 'node:path';
import { execa } from 'execa';
import locales from 'i18n';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger';
// requires node 21 or later
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outputDir = __dirname + '/../../built/_frontend_vite_';
/**
* @return {Promise<void>}
*/
async function viteBuild() {
await execa('vite', ['build'], {
cwd: __dirname,
stdout: process.stdout,
stderr: process.stderr,
});
}
async function buildAllLocale() {
const logger = createLogger()
const inliner = await LocaleInliner.create({
outputDir,
logger,
scriptsDir: 'scripts',
i18nFile: 'src/i18n.ts',
})
await inliner.loadFiles();
inliner.collectsModifications();
await inliner.saveAllLocales(locales);
if (logger.errorCount > 0) {
throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
}
}
async function build() {
await fs.rm(outputDir, { recursive: true, force: true });
await viteBuild();
await buildAllLocale();
}
await build();

View File

@@ -14,6 +14,7 @@ export default [
...pluginVue.configs['flat/recommended'],
{
files: ['src/**/*.{ts,vue}'],
ignores: ['**/*.stories.ts'],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
@@ -30,9 +31,6 @@ export default [
_VERSION_: false,
_ENV_: false,
_PERF_PREFIX_: false,
_DATA_TRANSFER_DRIVE_FILE_: false,
_DATA_TRANSFER_DRIVE_FOLDER_: false,
_DATA_TRANSFER_DECK_COLUMN_: false,
},
parser,
parserOptions: {
@@ -149,12 +147,23 @@ export default [
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/html-self-closing': ['error', {
html: {
void: 'any',
normal: 'never',
component: 'any',
},
svg: 'any',
math: 'any',
}],
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', {
autofix: true,
}],
'vue/attribute-hyphenation': ['error', 'never'],
'vue/no-mutating-props': ['error', {
shallowOnly: true,
}],
},
},
];

View File

@@ -8,7 +8,7 @@
import { parse as vueSfcParse } from 'vue/compiler-sfc';
import {
createLogger,
EnvironmentModuleGraph,
type EnvironmentModuleGraph,
type LogErrorOptions,
type LogOptions,
normalizePath,
@@ -16,7 +16,6 @@ import {
type PluginOption
} from 'vite';
import fs from 'node:fs';
import { glob } from 'glob';
import JSON5 from 'json5';
import MagicString, { SourceMap } from 'magic-string';
import path from 'node:path'
@@ -39,6 +38,7 @@ export interface SearchIndexItem {
path?: string;
label: string;
keywords: string[];
texts: string[];
icon?: string;
inlining?: string[];
}
@@ -227,14 +227,14 @@ function extractElementText2Inner(node: TemplateChildNode, processingNodeName: s
// region extractUsageInfoFromTemplateAst
/**
* SearchLabel/SearchKeyword/SearchIconを探して抽出する関数
* SearchLabel/SearchText/SearchIconを探して抽出する関数
*/
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } {
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null; texts: string[]; icon: string | null; } {
let label: string | null | undefined = undefined;
let icon: string | null | undefined = undefined;
const keywords: string[] = [];
const texts: string[] = [];
logger.info(`Extracting labels and keywords from ${nodes.length} nodes`);
logger.info(`Extracting labels and texts from ${nodes.length} nodes`);
walkVueElements(nodes, null, (node) => {
switch (node.tag) {
@@ -248,10 +248,10 @@ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: stri
label = extractElementText(node, id);
return;
case 'SearchKeyword':
case 'SearchText':
const content = extractElementText(node, id);
if (content) {
keywords.push(content);
texts.push(content);
}
return;
case 'SearchIcon':
@@ -278,8 +278,8 @@ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: stri
});
// デバッグ情報
logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`);
return { label: label ?? null, keywords, icon: icon ?? null };
logger.info(`Extraction completed: label=${label}, text=[${texts.join(', ')}, icon=${icon}]`);
return { label: label ?? null, texts, icon: icon ?? null };
}
function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null {
@@ -351,33 +351,36 @@ function extractUsageInfoFromTemplateAst(
parentId: parentId ?? undefined,
label: '', // デフォルト値
keywords: [],
texts: [],
};
// バインドプロパティを取得
const path = getStringProp(findAttribute(node.props, 'path'), id)
const icon = getStringProp(findAttribute(node.props, 'icon'), id)
const label = getStringProp(findAttribute(node.props, 'label'), id)
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id)
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id)
const path = getStringProp(findAttribute(node.props, 'path'), id);
const icon = getStringProp(findAttribute(node.props, 'icon'), id);
const label = getStringProp(findAttribute(node.props, 'label'), id);
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id);
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id);
const texts = getStringArrayProp(findAttribute(node.props, 'texts'), id);
if (path) markerInfo.path = path;
if (icon) markerInfo.icon = icon;
if (label) markerInfo.label = label;
if (inlining) markerInfo.inlining = inlining;
if (keywords) markerInfo.keywords = keywords;
if (texts) markerInfo.texts = texts;
//pathがない場合はファイルパスを設定
// pathがない場合はファイルパスを設定
if (markerInfo.path == null && parentId == null) {
markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1];
}
// SearchLabelとSearchKeywordを抽出 (AST全体を探索)
// SearchLabelとSearchTextを抽出 (AST全体を探索)
{
const extracted = extractSugarTags(node.children, id);
if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`);
if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`);
markerInfo.label = extracted.label ?? markerInfo.label ?? '';
markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords];
markerInfo.texts = [...extracted.texts, ...markerInfo.texts];
markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined;
}
@@ -720,7 +723,7 @@ export function pluginCreateSearchIndexVirtualModule(options: Options, asigner:
async load(id) {
if (id == '\0' + allSearchIndexFile) {
const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat());
const files = options.targetFilePaths.map((filePathPattern) => fs.globSync(filePathPattern)).flat();
let generatedFile = '';
let arrayElements = '';
for (let file of files) {

View File

@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import path from 'node:path'
import locales from 'i18n';
const localesDir = path.resolve(__dirname, '../../../locales')
/**
* 外部ファイルを監視し、必要に応じてwebSocketでメッセージを送るViteプラグイン
* @returns {import('vite').Plugin}
*/
export default function pluginWatchLocales() {
return {
name: 'watch-locales',
configureServer(server) {
const localeYmlPaths = Object.keys(locales).map(locale => path.join(localesDir, `${locale}.yml`));
// watcherにパスを追加
server.watcher.add(localeYmlPaths);
server.watcher.on('change', (filePath) => {
if (localeYmlPaths.includes(filePath)) {
server.ws.send({
type: 'custom',
event: 'locale-update',
data: filePath.match(/([^\/]+)\.yml$/)?.[1] || null,
})
}
});
},
};
}

View File

@@ -4,9 +4,9 @@
"type": "module",
"scripts": {
"watch": "vite",
"build": "vite build",
"build": "tsx build.ts",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
"chromatic": "chromatic",
"test": "vitest --run --globals",
@@ -17,131 +17,133 @@
},
"dependencies": {
"@analytics/google-analytics": "1.1.0",
"@discordapp/twemoji": "15.1.0",
"@discordapp/twemoji": "16.0.1",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@sentry/vue": "9.12.0",
"@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.31.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.2.3",
"@vue/compiler-sfc": "3.5.13",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.16",
"astring": "1.9.0",
"broadcast-channel": "7.1.0",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@sentry/vue": "10.32.1",
"@syuilo/aiscript": "1.2.1",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.3",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
"analytics": "0.8.19",
"broadcast-channel": "7.2.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.8",
"canvas-confetti": "1.9.4",
"chart.js": "4.5.1",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.1.1",
"chartjs-chart-matrix": "3.0.0",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "11.28.0",
"chromatic": "13.3.4",
"compare-versions": "6.1.1",
"cropperjs": "2.0.0",
"cropperjs": "2.1.0",
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"execa": "9.6.1",
"exifreader": "4.33.1",
"frontend-shared": "workspace:*",
"idb-keyval": "6.2.1",
"i18n": "workspace:*",
"icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2",
"insert-text-at-cursor": "0.3.0",
"ios-haptics": "0.1.4",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"magic-string": "0.30.17",
"matter-js": "0.20.0",
"mfm-js": "0.24.0",
"mediabunny": "1.27.2",
"mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*",
"misskey-mahjong": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"rollup": "4.39.0",
"sanitize-html": "2.15.0",
"sass": "1.86.3",
"shiki": "3.2.2",
"strict-event-emitter-types": "2.0.0",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
"rollup": "4.54.0",
"sanitize-html": "2.17.0",
"sass": "1.97.1",
"shiki": "3.20.0",
"textarea-caret": "3.1.0",
"three": "0.175.0",
"three": "0.182.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0",
"typescript": "5.8.3",
"uuid": "11.1.0",
"v-code-diff": "1.13.1",
"vite": "6.3.1",
"vue": "3.5.13",
"vuedraggable": "next",
"vite": "7.3.0",
"vue": "3.5.26",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.0",
"@storybook/addon-actions": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-mdx-gfm": "8.6.12",
"@storybook/addon-storysource": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/components": "8.6.12",
"@storybook/core-events": "8.6.12",
"@storybook/manager-api": "8.6.12",
"@storybook/preview-api": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@storybook/theming": "8.6.12",
"@storybook/types": "8.6.12",
"@storybook/vue3": "8.6.12",
"@storybook/vue3-vite": "8.6.12",
"@misskey-dev/summaly": "5.2.5",
"@storybook/addon-essentials": "8.6.15",
"@storybook/addon-interactions": "8.6.15",
"@storybook/addon-links": "10.1.10",
"@storybook/addon-mdx-gfm": "8.6.15",
"@storybook/addon-storysource": "8.6.15",
"@storybook/blocks": "8.6.15",
"@storybook/components": "8.6.15",
"@storybook/core-events": "8.6.15",
"@storybook/manager-api": "8.6.15",
"@storybook/preview-api": "8.6.15",
"@storybook/react": "10.1.10",
"@storybook/react-vite": "10.1.10",
"@storybook/test": "8.6.15",
"@storybook/theming": "8.6.15",
"@storybook/types": "8.6.15",
"@storybook/vue3": "10.1.10",
"@storybook/vue3-vite": "10.1.10",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.7",
"@types/matter-js": "0.19.8",
"@types/micromatch": "4.0.9",
"@types/node": "22.14.0",
"@types/estree": "1.0.8",
"@types/insert-text-at-cursor": "0.3.2",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.4",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.15.0",
"@types/sanitize-html": "2.16.0",
"@types/seedrandom": "3.0.8",
"@types/textarea-caret": "3.0.4",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitest/coverage-v8": "3.1.1",
"@vue/compiler-core": "3.5.13",
"@vue/runtime-core": "3.5.13",
"acorn": "8.14.1",
"cross-env": "7.0.3",
"cypress": "14.3.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "10.0.0",
"fast-glob": "3.3.3",
"happy-dom": "17.4.4",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"@vitest/coverage-v8": "4.0.16",
"@vue/compiler-core": "3.5.26",
"acorn": "8.15.0",
"astring": "1.9.0",
"cross-env": "10.1.0",
"cypress": "15.8.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.6.2",
"estree-walker": "3.0.3",
"happy-dom": "20.0.11",
"intersection-observer": "0.12.2",
"magic-string": "0.30.21",
"micromatch": "4.0.8",
"minimatch": "10.0.1",
"msw": "2.7.3",
"msw-storybook-addon": "2.0.4",
"nodemon": "3.1.9",
"prettier": "3.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"minimatch": "10.1.1",
"msw": "2.12.6",
"msw-storybook-addon": "2.0.6",
"nodemon": "3.1.11",
"prettier": "3.7.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.11",
"storybook": "8.6.12",
"start-server-and-test": "2.1.3",
"storybook": "10.1.10",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.21.0",
"vite-plugin-glsl": "1.5.5",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.1.1",
"vitest": "4.0.16",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "2.2.8",
"vue-eslint-parser": "10.1.3",
"vue-tsc": "2.2.8"
"vue-component-type-helpers": "3.2.1",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.2.1"
}
}

View File

@@ -0,0 +1,338 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use strict';
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED', e);
};
window.onunhandledrejection = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e);
};
let forceError = localStorage.getItem('forceError');
if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
return;
}
//#region Detect language
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
localStorage.setItem('lang', lang);
//#endregion
//#region Script
async function importAppScript() {
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT', e);
});
}
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') {
importAppScript();
} else {
window.addEventListener('DOMContentLoaded', () => {
importAppScript();
});
}
//#endregion
let isSafeMode = (localStorage.getItem('isSafeMode') === 'true');
if (!isSafeMode) {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('safemode') && urlParams.get('safemode') === 'true') {
localStorage.setItem('isSafeMode', 'true');
isSafeMode = true;
}
}
//#region Theme
if (!isSafeMode) {
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
}
}
}
}
}
const colorScheme = localStorage.getItem('colorScheme');
if (colorScheme) {
document.documentElement.style.setProperty('color-scheme', colorScheme);
}
//#endregion
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
}
const useSystemFont = localStorage.getItem('useSystemFont');
if (useSystemFont) {
document.documentElement.classList.add('useSystemFont');
}
if (!isSafeMode) {
const customCss = localStorage.getItem('customCss');
if (customCss && customCss.length > 0) {
const style = document.createElement('style');
style.innerHTML = customCss;
document.head.appendChild(style);
}
}
async function addStyle(styleText) {
let css = document.createElement('style');
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
}
async function renderError(code, details) {
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
messages = Object.assign({
title: 'Failed to initialize Misskey',
solution: 'The following actions may solve the problem.',
solution1: 'Update your os and browser',
solution2: 'Disable an adblocker',
solution3: 'Clear the browser cache',
solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
otherOption: 'Other options',
otherOption1: 'Clear preferences and cache',
otherOption2: 'Start the simple client',
otherOption3: 'Start the repair tool',
otherOption4: 'Start Misskey in safe mode',
reload: 'Reload',
}, messages);
const safeModeUrl = new URL(window.location.href);
safeModeUrl.searchParams.set('safemode', 'true');
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
document.body.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
<h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">${messages?.reload}</span>
</button>
<p><b>${messages.solution}</b></p>
<p>${messages.solution1}</p>
<p>${messages.solution2}</p>
<p>${messages.solution3}</p>
<p>${messages.solution4}</p>
<details style="color: #86b300;">
<summary>${messages.otherOption}</summary>
<a href="${safeModeUrl}">
<button class="button-small">
<span class="button-label-small">${messages.otherOption4}</span>
</button>
</a>
<br>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">${messages.otherOption1}</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">${messages.otherOption2}</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">${messages.otherOption3}</span>
</button>
</a>
</details>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.id = 'errorInfo';
detailsElement.innerHTML = `
<br>
<summary>
<code>ERROR CODE: ${code}</code>
</summary>
<code>${details.toString()} ${JSON.stringify(details)}</code>`;
errorsElement.appendChild(detailsElement);
addStyle(`
* {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#misskey_app,
#splash {
display: none !important;
}
body,
html {
background-color: #222;
color: #dfddcc;
justify-content: center;
margin: auto;
padding: 10px;
text-align: center;
}
button {
border-radius: 999px;
padding: 0px 12px 0px 12px;
border: none;
cursor: pointer;
margin-bottom: 12px;
}
.button-big {
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
line-height: 50px;
}
.button-big:hover {
background: rgb(153, 204, 0);
}
.button-small {
background: #444;
line-height: 40px;
}
.button-small:hover {
background: #555;
}
.button-label-big {
color: #222;
font-weight: bold;
font-size: 1.2em;
padding: 12px;
}
.button-label-small {
color: rgb(153, 204, 0);
font-size: 16px;
padding: 12px;
}
a {
color: rgb(134, 179, 0);
text-decoration: none;
}
p,
li {
font-size: 16px;
}
.icon-warning {
color: #dec340;
height: 4rem;
padding-top: 2rem;
}
h1 {
font-size: 1.5em;
margin: 1em;
}
code {
font-family: Fira, FiraCode, monospace;
}
#errorInfo {
background: #333;
margin-bottom: 2rem;
padding: 0.5rem 1rem;
width: 40rem;
border-radius: 10px;
justify-content: center;
margin: auto;
}
#errorInfo summary {
cursor: pointer;
}
#errorInfo summary > * {
display: inline;
}
@media screen and (max-width: 500px) {
#errorInfo {
width: 50%;
}
}`);
}
})();

View File

@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
html {
background-color: var(--MI_THEME-bg);
color: var(--MI_THEME-fg);
}
#splash {
position: fixed;
z-index: 10000;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
cursor: wait;
background-color: var(--MI_THEME-bg);
opacity: 1;
transition: opacity 0.5s ease;
}
#splashIcon {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 64px;
height: 64px;
border-radius: 10px;
pointer-events: none;
}
#splashSpinner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
display: inline-block;
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--MI_THEME-accent);
}
#splashSpinner > .spinner {
position: absolute;
top: 0;
left: 0;
width: 28px;
height: 28px;
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 1.5;
}
#splashSpinner > .spinner.bg {
opacity: 0.275;
}
#splashSpinner > .spinner.fg {
animation: splashSpinner 0.5s linear infinite;
}
@keyframes splashSpinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -6,13 +6,17 @@
// https://vitejs.dev/config/build-options.html#build-modulepreload
import 'vite/modulepreload-polyfill';
import '@tabler/icons-webfont/dist/tabler-icons.scss';
if (import.meta.env.DEV) {
await import('@tabler/icons-webfont/dist/tabler-icons.scss');
} else {
await import('icons-subsetter/built/tabler-icons-frontend.css');
}
import '@/style.scss';
import { mainBoot } from '@/boot/main-boot.js';
import { subBoot } from '@/boot/sub-boot.js';
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/install-extensions'];
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/verify-email', '/install-extensions'];
if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) {
subBoot();

View File

@@ -23,7 +23,7 @@ export async function getAccounts(): Promise<{
host: string;
id: Misskey.entities.User['id'];
username: Misskey.entities.User['username'];
user?: Misskey.entities.User | null;
user?: Misskey.entities.MeDetailed | null;
token: string | null;
}[]> {
const tokens = store.s.accountTokens;
@@ -38,7 +38,7 @@ export async function getAccounts(): Promise<{
}));
}
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
async function addAccount(host: string, user: Misskey.entities.MeDetailed, token: AccountWithToken['token']) {
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
@@ -126,10 +126,10 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
if (!$i) return;
const token = $i.token;
for (const key of Object.keys($i)) {
delete $i[key];
delete $i[key as keyof typeof $i];
}
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
($i[key as keyof typeof accountData] as any) = value;
}
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
$i.token = token;
@@ -139,7 +139,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
export function updateCurrentAccountPartial(accountData: Partial<Misskey.entities.MeDetailed>) {
if (!$i) return;
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
($i[key as keyof typeof accountData] as any) = value;
}
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
@@ -149,9 +149,10 @@ export function updateCurrentAccountPartial(accountData: Partial<Misskey.entitie
export async function refreshCurrentAccount() {
if (!$i) return;
const me = $i;
return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => {
if (reason === isAccountDeleted) {
removeAccount(host, $i.id);
removeAccount(host, me.id);
if (Object.keys(store.s.accountTokens).length > 0) {
login(Object.values(store.s.accountTokens)[0]);
} else {
@@ -210,50 +211,79 @@ export async function switchAccount(host: string, id: string) {
}
}
export async function openAccountMenu(opts: {
export async function getAccountMenu(opts: {
includeCurrentAccount?: boolean;
withExtraOperation: boolean;
active?: Misskey.entities.User['id'];
onChoose?: (account: Misskey.entities.User) => void;
}, ev: MouseEvent) {
if (!$i) return;
onChoose?: (account: Misskey.entities.MeDetailed) => void;
}) {
if ($i == null) throw new Error('No current account');
const me = $i;
function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem {
const callback = opts.onChoose;
function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.MeDetailed | null | undefined, token: string | null): MenuItem {
if (account) {
return {
type: 'user' as const,
user: account,
active: opts.active != null ? opts.active === id : false,
action: async () => {
if (opts.onChoose) {
opts.onChoose(account);
if (callback) {
callback(account);
} else {
switchAccount(host, id);
}
},
};
} else {
} else if (token != null) {
return {
type: 'button' as const,
text: username,
active: opts.active != null ? opts.active === id : false,
action: async () => {
if (opts.onChoose) {
if (callback) {
fetchAccount(token, id).then(account => {
opts.onChoose(account);
callback(account);
});
} else {
switchAccount(host, id);
}
},
};
} else { // プロファイルを復元した場合などはアカウントのトークンや詳細情報はstoreにキャッシュされていない
return {
type: 'button' as const,
text: username,
active: opts.active != null ? opts.active === id : false,
action: async () => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
initialUsername: username,
}, {
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i });
if (callback) {
fetchAccount(res.i, id).then(account => {
callback(account);
});
} else {
switchAccount(host, id);
}
},
closed: () => {
dispose();
},
});
},
};
}
}
const menuItems: MenuItem[] = [];
// TODO: $iのホストも比較したいけど通常null
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== me.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
if (opts.withExtraOperation) {
menuItems.push({
@@ -308,9 +338,7 @@ export async function openAccountMenu(opts: {
menuItems.push(...accountItems);
}
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
align: 'left',
});
return menuItems;
}
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {

View File

@@ -40,48 +40,109 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
LOCALE: values.STR(lang),
SERVER_URL: values.STR(url),
'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
utils.assertString(title);
utils.assertString(text);
if (type != null) {
assertStringAndIsIn(type, DIALOG_TYPES);
'Mk:dialog': values.FN_NATIVE(async ([_title, _text, _type]) => {
let title: string | undefined = undefined;
let text: string | undefined = undefined;
let type: typeof DIALOG_TYPES[number] = 'info';
if (_title != null) {
if (utils.isString(_title)) {
title = _title.value;
} else {
utils.assertNull(_title);
}
}
if (_text != null) {
if (utils.isString(_text)) {
text = _text.value;
} else {
utils.assertNull(_text);
}
}
if (_type != null) {
if (utils.isString(_type)) {
assertStringAndIsIn(_type, DIALOG_TYPES);
type = _type.value;
} else {
utils.assertNull(_type);
}
}
await os.alert({
type: type ? type.value : 'info',
title: title.value,
text: text.value,
type,
title,
text,
});
return values.NULL;
}),
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
utils.assertString(title);
utils.assertString(text);
if (type != null) {
assertStringAndIsIn(type, DIALOG_TYPES);
'Mk:confirm': values.FN_NATIVE(async ([_title, _text, _type]) => {
let title: string | undefined = undefined;
let text: string | undefined = undefined;
let type: typeof DIALOG_TYPES[number] = 'question';
if (_title != null) {
if (utils.isString(_title)) {
title = _title.value;
} else {
utils.assertNull(_title);
}
}
if (_text != null) {
if (utils.isString(_text)) {
text = _text.value;
} else {
utils.assertNull(_text);
}
}
if (_type != null) {
if (utils.isString(_type)) {
assertStringAndIsIn(_type, DIALOG_TYPES);
type = _type.value;
} else {
utils.assertNull(_type);
}
}
const confirm = await os.confirm({
type: type ? type.value : 'question',
title: title.value,
text: text.value,
type,
title,
text,
});
return confirm.canceled ? values.FALSE : values.TRUE;
}),
'Mk:toast': values.FN_NATIVE(([text]) => {
utils.assertString(text);
os.toast(text.value);
return values.NULL;
}),
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
utils.assertString(ep);
if (ep.value.includes('://')) {
if (ep.value.includes('://') || ep.value.includes('..')) {
throw new errors.AiScriptRuntimeError('invalid endpoint');
}
if (token) {
let actualToken: string | null = null;
if (token != null && !utils.isNull(token)) {
utils.assertString(token);
// バグがあればundefinedもあり得るため念のため
if (typeof token.value !== 'string') throw new Error('invalid token');
if (typeof token.value !== 'string') throw new errors.AiScriptRuntimeError('invalid token');
actualToken = token.value;
}
const actualToken: string | null = token?.value ?? opts.token ?? null;
if (actualToken == null) {
actualToken = opts.token ?? null;
}
if (param == null) {
throw new errors.AiScriptRuntimeError('expected param');
}
utils.assertObject(param);
return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => {
return misskeyApi(ep.value as keyof Misskey.Endpoints, utils.valToJs(param) as object, actualToken).then(res => {
return utils.jsToVal(res);
}, err => {
return values.ERROR('request_failed', utils.jsToVal(err));

View File

@@ -4,11 +4,11 @@
*/
import { utils, values } from '@syuilo/aiscript';
import { v4 as uuid } from 'uuid';
import { ref } from 'vue';
import type { Ref } from 'vue';
import * as Misskey from 'misskey-js';
import { assertStringAndIsIn } from './common.js';
import type { Ref } from 'vue';
import { genId } from '@/utility/id.js';
const ALIGNS = ['left', 'center', 'right'] as const;
const FONTS = ['serif', 'sans-serif', 'monospace'] as const;
@@ -21,16 +21,15 @@ type BorderStyle = (typeof BORDER_STYLES)[number];
export type AsUiComponentBase = {
id: string;
hidden?: boolean;
children?: AsUiComponent['id'][];
};
export type AsUiRoot = AsUiComponentBase & {
type: 'root';
children: AsUiComponent['id'][];
};
export type AsUiContainer = AsUiComponentBase & {
type: 'container';
children?: AsUiComponent['id'][];
align?: Align;
bgColor?: string;
fgColor?: string;
@@ -123,7 +122,6 @@ export type AsUiSelect = AsUiComponentBase & {
export type AsUiFolder = AsUiComponentBase & {
type: 'folder';
children?: AsUiComponent['id'][];
title?: string;
opened?: boolean;
};
@@ -533,7 +531,7 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn
export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
const instances = {};
const instances = {} as Record<string, values.VObj>;
function createComponentInstance<T extends AsUiComponent, C>(
type: T['type'],
@@ -543,7 +541,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
call: C,
) {
if (id) utils.assertString(id);
const _id = id?.value ?? uuid();
const _id = id?.value ?? genId();
const component = ref({
...getOptions(def, call),
type,
@@ -557,7 +555,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
const updates = getOptions(def, call);
for (const update of def.value.keys()) {
if (!Object.hasOwn(updates, update)) continue;
component.value[update] = updates[update];
component.value[update] = updates[update as keyof Options<T>];
}
})],
]));

View File

@@ -90,6 +90,7 @@ export async function initAnalytics(instance: Misskey.entities.MetaDetailed) {
// Google Analytics
if (instance.googleAnalyticsMeasurementId) {
//@ts-expect-error Dynamic import
const { default: googleAnalytics } = await import('@analytics/google-analytics');
plugins.push(googleAnalytics({

View File

@@ -3,22 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, watch, version as vueVersion } from 'vue';
import { watch, version as vueVersion } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale, apiUrl } from '@@/js/config.js';
import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
import type { App } from 'vue';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
import components from '@/components/index.js';
import { applyTheme } from '@/theme.js';
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
import { updateI18n, i18n } from '@/i18n.js';
import { i18n } from '@/i18n.js';
import { refreshCurrentAccount, login } from '@/accounts.js';
import { store } from '@/store.js';
import { fetchInstance, instance } from '@/instance.js';
import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js';
import { updateDeviceKind } from '@/utility/device-kind.js';
import { reloadChannel } from '@/utility/unison-reload.js';
import { getUrlWithoutLoginId } from '@/utility/login-id.js';
import { getAccountFromId } from '@/utility/get-account-from-id.js';
@@ -28,6 +29,7 @@ import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { $i } from '@/i.js';
import { launchPlugins } from '@/plugin.js';
export async function common(createVue: () => Promise<App<Element>>) {
console.info(`Misskey v${version}`);
@@ -67,9 +69,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
if (lastVersion !== version) {
miLocalStorage.setItem('lastVersion', version);
// テーマリビルドするため
miLocalStorage.removeItem('theme');
try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
isClientUpdated = true;
@@ -79,18 +78,20 @@ export async function common(createVue: () => Promise<App<Element>>) {
//#endregion
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
if (import.meta.hot) {
import.meta.hot.on('locale-update', async (updatedLang: string) => {
console.info(`Locale updated: ${updatedLang}`);
if (updatedLang === lang) {
await new Promise(resolve => {
window.setTimeout(resolve, 500);
});
// fetch with cache: 'no-store' to ensure the latest locale is fetched
await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text());
window.location.reload();
}
});
}
//#endregion
@@ -108,13 +109,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
else window.location.reload();
});
// If mobile, insert the viewport meta tag
if (['smartphone', 'tablet'].includes(deviceKind)) {
const viewport = window.document.getElementsByName('viewport').item(0);
viewport.setAttribute('content',
`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
}
//#region Set lang attr
const html = window.document.documentElement;
html.setAttribute('lang', lang);
@@ -147,31 +141,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
}
//#endregion
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
watch(store.r.darkMode, (darkMode) => {
applyTheme(darkMode
? (prefer.s.darkTheme ?? defaultDarkTheme)
: (prefer.s.lightTheme ?? defaultLightTheme),
);
}, { immediate: miLocalStorage.getItem('theme') == null });
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
const darkTheme = prefer.model('darkTheme');
const lightTheme = prefer.model('lightTheme');
watch(darkTheme, (theme) => {
if (store.s.darkMode) {
applyTheme(theme ?? defaultDarkTheme);
}
});
watch(lightTheme, (theme) => {
if (!store.s.darkMode) {
applyTheme(theme ?? defaultLightTheme);
}
});
//#region Sync dark mode
if (prefer.s.syncDeviceDarkMode) {
store.set('darkMode', isDeviceDarkmode());
@@ -184,17 +153,43 @@ export async function common(createVue: () => Promise<App<Element>>) {
});
//#endregion
if (prefer.s.darkTheme && store.s.darkMode) {
if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);
} else if (prefer.s.lightTheme && !store.s.darkMode) {
if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme);
}
fetchInstanceMetaPromise.then(() => {
if (!isSafeMode) {
// TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア
if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme));
if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme));
});
}
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
// NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため)
// NOTE: この処理は必ずサーバーテーマ適用処理より後に来ること(二重applyTheme発火を防ぐため)
// see: https://github.com/misskey-dev/misskey/issues/16562
watch(store.r.darkMode, (darkMode) => {
const theme = (() => {
if (darkMode) {
return isSafeMode ? defaultDarkTheme : (prefer.s.darkTheme ?? defaultDarkTheme);
} else {
return isSafeMode ? defaultLightTheme : (prefer.s.lightTheme ?? defaultLightTheme);
}
})();
applyTheme(theme);
}, { immediate: true });
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
if (!isSafeMode) {
watch(prefer.r.darkTheme, (theme) => {
if (store.s.darkMode) {
applyTheme(theme ?? defaultDarkTheme);
}
});
watch(prefer.r.lightTheme, (theme) => {
if (!store.s.darkMode) {
applyTheme(theme ?? defaultLightTheme);
}
});
}
watch(prefer.r.overridedDeviceKind, (kind) => {
updateDeviceKind(kind);
@@ -326,6 +321,12 @@ export async function common(createVue: () => Promise<App<Element>>) {
});
}
try {
await launchPlugins();
} catch (error) {
console.error('Failed to launch plugins:', error);
}
app.mount(rootEl);
// boot.jsのやつを解除

View File

@@ -26,10 +26,10 @@ import { mainRouter } from '@/router.js';
import { makeHotkey } from '@/utility/hotkey.js';
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { launchPlugins } from '@/plugin.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
import { signout } from '@/signout.js';
import { migrateOldSettings } from '@/pref-migrate.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { isBirthday } from '@/utility/is-birthday.js';
export async function mainBoot() {
const { isClientUpdated, lastVersion } = await common(async () => {
@@ -79,41 +79,6 @@ export async function mainBoot() {
}
}
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
launchPlugins();
try {
if (prefer.s.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
@@ -169,8 +134,6 @@ export async function mainBoot() {
}
}
stream.on('announcementCreated', onAnnouncementCreated);
if ($i.isDeleted) {
alert({
type: 'warning',
@@ -182,12 +145,8 @@ export async function mainBoot() {
const m = now.getMonth() + 1;
const d = now.getDate();
if ($i.birthday) {
const bm = parseInt($i.birthday.split('-')[1]);
const bd = parseInt($i.birthday.split('-')[2]);
if (m === bm && d === bd) {
claimAchievement('loggedInOnBirthday');
}
if (isBirthday($i, now)) {
claimAchievement('loggedInOnBirthday');
}
if (m === 1 && d === 1) {
@@ -341,60 +300,81 @@ export async function mainBoot() {
});
}
if ('Notification' in window) {
// 許可を得ていなかったらリクエスト
if (Notification.permission === 'default') {
Notification.requestPermission();
}
if (store.s.realtimeMode) {
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
stream.on('announcementCreated', onAnnouncementCreated);
const main = markRaw(stream.useChannel('main', null, 'System'));
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
}
const main = markRaw(stream.useChannel('main', null, 'System'));
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
signout();
});
}
// shortcut
let safemodeRequestCount = 0;
let safemodeRequestTimer: number | null = null;
const keymap = {
'p|n': () => {
if ($i == null) return;
@@ -406,6 +386,24 @@ export async function mainBoot() {
's': () => {
mainRouter.push('/search');
},
'g': {
callback: () => {
// mを5回押すとセーフモードに入る
safemodeRequestCount++;
if (safemodeRequestCount >= 5) {
miLocalStorage.setItem('isSafeMode', 'true');
unisonReload();
} else {
if (safemodeRequestTimer != null) {
window.clearTimeout(safemodeRequestTimer);
}
safemodeRequestTimer = window.setTimeout(() => {
safemodeRequestCount = 0;
}, 300);
}
},
allowRepeat: true,
},
} as const satisfies Keymap;
window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false });

View File

@@ -7,8 +7,8 @@ import * as Misskey from 'misskey-js';
import { Cache } from '@/utility/cache.js';
import { misskeyApi } from '@/utility/misskey-api.js';
export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list'));
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list', { limit: 30 }));
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list', { limit: 30 }));
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list'));
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list', { limit: 30 }));
export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));

View File

@@ -115,7 +115,7 @@ watch(moderationNote, async () => {
});
});
function resolve(resolvedAs) {
function resolve(resolvedAs: 'accept' | 'reject' | null) {
os.apiWithDialog('admin/resolve-abuse-user-report', {
reportId: props.report.id,
resolvedAs,
@@ -132,7 +132,7 @@ function forward() {
});
}
function showMenu(ev: MouseEvent) {
function showMenu(ev: PointerEvent) {
os.popupMenu([{
icon: 'ti ti-hash',
text: 'Copy ID',

View File

@@ -2,14 +2,13 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import type { StoryObj } from '@storybook/vue3';
import { action } from 'storybook/actions';
import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAbuseReportWindow from './MkAbuseReportWindow.vue';
import type { StoryObj } from '@storybook/vue3';
export const Default = {
render(args) {
return {

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</I18n>
</template>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
<div class="_gaps_m" :class="$style.root">
<div class="">
<MkTextarea v-model="comment">
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
</div>
</div>
</MkSpacer>
</div>
</MkWindow>
</template>

View File

@@ -4,7 +4,7 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';

View File

@@ -16,20 +16,20 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.iconFrame_platinum]: ACHIEVEMENT_BADGES[achievement.name].frame === 'platinum',
}]"
>
<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg ?? '' }">
<img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
</div>
</div>
</div>
<div :class="$style.body">
<div :class="$style.header">
<span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span>
<span :class="$style.title">{{ i18n.ts._achievements._types[`_${achievement.name}`].title }}</span>
<span :class="$style.time">
<time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time>
</span>
</div>
<div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types['_' + achievement.name].description : '???' }}</div>
<div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor && withDescription" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div>
<div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types[`_${achievement.name}`].description : '???' }}</div>
<div v-if="'flavor' in i18n.ts._achievements._types[`_${achievement.name}`] && withDescription" :class="$style.flavor">{{ (i18n.ts._achievements._types[`_${achievement.name}`] as { flavor: string; }).flavor }}</div>
</div>
</div>
<template v-if="withLocked">
@@ -54,15 +54,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { onMounted, ref, computed } from 'vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js';
const props = withDefaults(defineProps<{
user: Misskey.entities.User;
withLocked: boolean;
withDescription: boolean;
withLocked?: boolean;
withDescription?: boolean;
}>(), {
withLocked: true,
withDescription: true,
@@ -71,7 +70,7 @@ const props = withDefaults(defineProps<{
const achievements = ref<Misskey.entities.UsersAchievementsResponse | null>(null);
const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x)));
function fetch() {
function _fetch_() {
misskeyApi('users/achievements', { userId: props.user.id }).then(res => {
achievements.value = [];
for (const t of ACHIEVEMENT_TYPES) {
@@ -84,11 +83,11 @@ function fetch() {
function clickHere() {
claimAchievement('clickedClickHere');
fetch();
_fetch_();
}
onMounted(() => {
fetch();
_fetch_();
});
</script>

View File

@@ -0,0 +1,111 @@
#version 300 es
precision mediump float;
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
vec3 mod289(vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec2 mod289(vec2 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec3 permute(vec3 x) {
return mod289(((x*34.0)+1.0)*x);
}
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m = m*m;
m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
in vec2 in_uv;
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_spread;
uniform float u_speed;
uniform float u_warp;
uniform float u_focus;
uniform float u_itensity;
out vec4 out_color;
float circle(in vec2 _pos, in vec2 _origin, in float _radius) {
float SPREAD = 0.7 * u_spread;
float SPEED = 0.00055 * u_speed;
float WARP = 1.5 * u_warp;
float FOCUS = 1.15 * u_focus;
vec2 dist = _pos - _origin;
float distortion = snoise(vec2(
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
)) * 0.5 + 0.5;
float feather = 0.01 + SPREAD * pow(distortion, FOCUS);
return 1.0 - smoothstep(
_radius - (_radius * feather),
_radius + (_radius * feather),
dot( dist, dist ) * 4.0
);
}
void main() {
vec3 green = vec3(1.0) - vec3(153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0);
vec3 purple = vec3(1.0) - vec3(195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0);
vec3 orange = vec3(1.0) - vec3(255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0);
float ratio = u_resolution.x / u_resolution.y;
vec2 uv = vec2(in_uv.x, in_uv.y / ratio) * 0.5 + 0.5;
vec3 color = vec3(0.0);
float greenMix = snoise(in_uv * 1.31 + u_time * 0.8 * 0.00017) * 0.5 + 0.5;
float purpleMix = snoise(in_uv * 1.26 + u_time * 0.8 * -0.0001) * 0.5 + 0.5;
float orangeMix = snoise(in_uv * 1.34 + u_time * 0.8 * 0.00015) * 0.5 + 0.5;
float alphaOne = 0.35 + 0.65 * pow(snoise(vec2(u_time * 0.00012, uv.x)) * 0.5 + 0.5, 1.2);
float alphaTwo = 0.35 + 0.65 * pow(snoise(vec2((u_time + 1561.0) * 0.00014, uv.x )) * 0.5 + 0.5, 1.2);
float alphaThree = 0.35 + 0.65 * pow(snoise(vec2((u_time + 3917.0) * 0.00013, uv.x )) * 0.5 + 0.5, 1.2);
color += vec3(circle(uv, vec2(0.22 + sin(u_time * 0.000201) * 0.06, 0.80 + cos(u_time * 0.000151) * 0.06), 0.15)) * alphaOne * (purple * purpleMix + orange * orangeMix);
color += vec3(circle(uv, vec2(0.90 + cos(u_time * 0.000166) * 0.06, 0.42 + sin(u_time * 0.000138) * 0.06), 0.18)) * alphaTwo * (green * greenMix + purple * purpleMix);
color += vec3(circle(uv, vec2(0.19 + sin(u_time * 0.000112) * 0.06, 0.25 + sin(u_time * 0.000192) * 0.06), 0.09)) * alphaThree * (orange * orangeMix);
color *= u_itensity + 1.0 * pow(snoise(vec2(in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009)) * 0.5 + 0.5, 2.0);
vec3 inverted = vec3(1.0) - color;
out_color = vec4(color, max(max(color.x, color.y), color.z));
}

View File

@@ -0,0 +1,15 @@
#version 300 es
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
in vec2 position;
uniform vec2 u_scale;
out vec2 in_uv;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
in_uv = position / u_scale;
}

View File

@@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
import isChromatic from 'chromatic/isChromatic';
import vertexShaderSource from './MkAnimBg.vertex.glsl';
import fragmentShaderSource from './MkAnimBg.fragment.glsl';
import { initShaderProgram } from '@/utility/webgl.js';
const canvasEl = useTemplateRef('canvasEl');
@@ -21,47 +24,6 @@ const props = withDefaults(defineProps<{
focus: 1.0,
});
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (shader == null) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert(
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
if (shaderProgram == null || vertexShader == null || fragmentShader == null) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert(
`failed to init shader: ${gl.getProgramInfoLog(
shaderProgram,
)}`,
);
return null;
}
return shaderProgram;
}
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
onMounted(() => {
@@ -71,8 +33,10 @@ onMounted(() => {
canvas.width = width;
canvas.height = height;
const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
if (gl == null) return;
const maybeGl = canvas.getContext('webgl2', { premultipliedAlpha: true });
if (maybeGl == null) return;
const gl = maybeGl;
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT);
@@ -80,128 +44,7 @@ onMounted(() => {
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const shaderProgram = initShaderProgram(gl, `
attribute vec2 vertex;
uniform vec2 u_scale;
varying vec2 v_pos;
void main() {
gl_Position = vec4(vertex, 0.0, 1.0);
v_pos = vertex / u_scale;
}
`, `
precision mediump float;
vec3 mod289(vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec2 mod289(vec2 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec3 permute(vec3 x) {
return mod289(((x*34.0)+1.0)*x);
}
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187,
0.366025403784439,
-0.577350269189626,
0.024390243902439);
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
uniform float u_time;
uniform vec2 u_resolution;
uniform float u_spread;
uniform float u_speed;
uniform float u_warp;
uniform float u_focus;
uniform float u_itensity;
varying vec2 v_pos;
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
float SPREAD = 0.7 * u_spread;
float SPEED = 0.00055 * u_speed;
float WARP = 1.5 * u_warp;
float FOCUS = 1.15 * u_focus;
vec2 dist = _pos - _origin;
float distortion = snoise( vec2(
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
) ) * 0.5 + 0.5;
float feather = 0.01 + SPREAD * pow( distortion, FOCUS );
return 1.0 - smoothstep(
_radius - ( _radius * feather ),
_radius + ( _radius * feather ),
dot( dist, dist ) * 4.0
);
}
void main() {
vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 );
vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 );
vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 );
float ratio = u_resolution.x / u_resolution.y;
vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5;
vec3 color = vec3( 0.0 );
float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 );
color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix );
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
vec3 inverted = vec3( 1.0 ) - color;
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
}
`);
const shaderProgram = initShaderProgram(gl, vertexShaderSource, fragmentShaderSource);
if (shaderProgram == null) return;
gl.useProgram(shaderProgram);
@@ -221,7 +64,7 @@ onMounted(() => {
gl.uniform1f(u_itensity, 0.5);
gl.uniform2fv(u_scale, [props.scale, props.scale]);
const vertex = gl.getAttribLocation(shaderProgram, 'vertex');
const vertex = gl.getAttribLocation(shaderProgram, 'position');
gl.enableVertexAttribArray(vertex);
gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);
@@ -229,8 +72,8 @@ onMounted(() => {
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
if (isChromatic()) {
gl!.uniform1f(u_time, 0);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
gl.uniform1f(u_time, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
} else {
function render(timeStamp: number) {
let sizeChanged = false;
@@ -249,8 +92,8 @@ onMounted(() => {
gl.viewport(0, 0, width, height);
}
gl!.uniform1f(u_time, timeStamp);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
gl.uniform1f(u_time, timeStamp);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
handle = window.requestAnimationFrame(render);
}
@@ -263,6 +106,8 @@ onUnmounted(() => {
if (handle) {
window.cancelAnimationFrame(handle);
}
// TODO: WebGLリソースの解放
});
</script>

View File

@@ -4,7 +4,7 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick">
<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="emit('closed')" @click="onBgClick">
<div ref="rootEl" :class="$style.root">
<div :class="$style.header">
<span :class="$style.icon">
@@ -16,13 +16,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.title">{{ announcement.title }}</span>
</div>
<div :class="$style.text"><Mfm :text="announcement.text"/></div>
<MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton>
<div ref="bottomEl"></div>
<div :class="$style.footer">
<MkButton
primary
full
:disabled="!hasReachedBottom"
@click="ok"
>{{ hasReachedBottom ? i18n.ts.close : i18n.ts.scrollToClose }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { onMounted, useTemplateRef } from 'vue';
import { onMounted, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@@ -32,12 +40,16 @@ import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
const props = withDefaults(defineProps<{
const props = defineProps<{
announcement: Misskey.entities.Announcement;
}>(), {
});
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const rootEl = useTemplateRef('rootEl');
const bottomEl = useTemplateRef('bottomEl');
const modal = useTemplateRef('modal');
async function ok() {
@@ -72,7 +84,34 @@ function onBgClick() {
});
}
const hasReachedBottom = ref(false);
onMounted(() => {
if (bottomEl.value && rootEl.value) {
const bottomElRect = bottomEl.value.getBoundingClientRect();
const rootElRect = rootEl.value.getBoundingClientRect();
if (
bottomElRect.top >= rootElRect.top &&
bottomElRect.top <= (rootElRect.bottom - 66) // 66 ≒ 75 * 0.9 (modalのアニメーション分)
) {
hasReachedBottom.value = true;
return;
}
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
hasReachedBottom.value = true;
observer.disconnect();
}
}
}, {
root: rootEl.value,
rootMargin: '0px 0px -75px 0px',
});
observer.observe(bottomEl.value);
}
});
</script>
@@ -80,9 +119,12 @@ onMounted(() => {
.root {
margin: auto;
position: relative;
padding: 32px;
padding: 32px 32px 0;
min-width: 320px;
max-width: 480px;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
@@ -103,4 +145,14 @@ onMounted(() => {
.text {
margin: 1em 0;
}
.footer {
position: sticky;
bottom: 0;
left: -32px;
backdrop-filter: var(--MI-blur, blur(15px));
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
margin: 0 -32px;
padding: 24px 32px;
}
</style>

View File

@@ -4,7 +4,7 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';

View File

@@ -4,23 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkSpacer :contentMax="700">
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<div>
<div class="_gaps_m">
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkSelect v-model="src">
<MkSelect v-model="src" :items="antennaSourcesSelectDef">
<template #label>{{ i18n.ts.antennaSource }}</template>
<option value="all">{{ i18n.ts._antennaSources.all }}</option>
<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
<option value="users">{{ i18n.ts._antennaSources.users }}</option>
<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
</MkSelect>
<MkSelect v-if="src === 'list'" v-model="userListId">
<MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef">
<template #label>{{ i18n.ts.userList }}</template>
<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
</MkSelect>
<MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
<template #label>{{ i18n.ts.users }}</template>
@@ -48,11 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</MkSpacer>
</div>
</template>
<script lang="ts" setup>
import { watch, ref } from 'vue';
import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { DeepPartial } from '@/utility/merge.js';
import MkButton from '@/components/MkButton.vue';
@@ -64,6 +58,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { deepMerge } from '@/utility/merge.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
id?: string;
@@ -99,9 +94,35 @@ const emit = defineEmits<{
(ev: 'deleted'): void,
}>();
const {
model: src,
def: antennaSourcesSelectDef,
} = useMkSelect({
items: [
{ value: 'all', label: i18n.ts._antennaSources.all },
//{ value: 'home', label: i18n.ts._antennaSources.homeTimeline },
{ value: 'users', label: i18n.ts._antennaSources.users },
//{ value: 'list', label: i18n.ts._antennaSources.userList },
{ value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist },
],
initialValue: initialAntenna.src,
});
const {
model: userListId,
def: userListsSelectDef,
} = useMkSelect({
items: computed(() => {
if (userLists.value == null) return [];
return userLists.value.map(list => ({
value: list.id,
label: list.name,
}));
}),
initialValue: initialAntenna.userListId,
});
const name = ref<string>(initialAntenna.name);
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src);
const userListId = ref<string | null>(initialAntenna.userListId);
const users = ref<string>(initialAntenna.users.join('\n'));
const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n'));
const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n'));

View File

@@ -4,7 +4,7 @@
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';

View File

@@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput>
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate">
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate">
<template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template>
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'postForm'" :class="$style.postForm">
@@ -65,15 +64,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { Ref } from 'vue';
import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSelect from '@/components/MkSelect.vue';
import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
import MkFolder from '@/components/MkFolder.vue';
import MkPostForm from '@/components/MkPostForm.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
const props = withDefaults(defineProps<{
component: AsUiComponent;
@@ -106,7 +106,7 @@ const containerStyle = computed(() => {
const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle;
const border = isBordered ? {
borderWidth: c.borderWidth ?? '1px',
borderWidth: `${c.borderWidth ?? 1}px`,
borderColor: c.borderColor ?? 'var(--MI_THEME-divider)',
borderStyle: c.borderStyle ?? 'solid',
} : undefined;
@@ -130,9 +130,21 @@ function onSwitchUpdate(v: boolean) {
}
}
const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null);
const {
model: valueForSelect,
def: selectDef,
} = useMkSelect({
items: computed(() => {
if (c.type !== 'select') return [];
return (c.items ?? []).map(item => ({
value: item.value,
label: item.text,
}));
}),
initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null,
});
function onSelectUpdate(v) {
function onSelectUpdate(v: string | null) {
valueForSelect.value = v;
if ('onChange' in c && c.onChange) {
c.onChange(v as never);

View File

@@ -167,9 +167,13 @@ async function init() {
for (const user of usersRes) {
if (users.value.has(user.id)) continue;
const account = accounts.find(a => a.id === user.id);
if (!account || account.token == null) continue;
users.value.set(user.id, {
...user,
token: accounts.find(a => a.id === user.id)!.token,
token: account.token,
});
}
}
@@ -179,7 +183,7 @@ async function init() {
init();
function clickAddAccount(ev: MouseEvent) {
function clickAddAccount(ev: PointerEvent) {
selectedUser.value = null;
os.popupMenu([{

View File

@@ -3,15 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { userDetailed } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAutocomplete from './MkAutocomplete.vue';
import MkInput from './MkInput.vue';
import type { StoryObj } from '@storybook/vue3';
import { tick } from '@/utility/test-utils.js';
const common = {
render(args) {
@@ -81,7 +80,7 @@ export const User = {
...common.args,
type: 'user',
},
async play({ canvasElement }) {
async play({ canvasElement }: { canvasElement: HTMLElement }) {
const canvas = within(canvasElement);
const input = canvas.getByRole('combobox');
await waitFor(() => userEvent.hover(input));
@@ -114,7 +113,7 @@ export const Hashtag = {
...common.args,
type: 'hashtag',
},
async play({ canvasElement }) {
async play({ canvasElement }: { canvasElement: HTMLElement }) {
const canvas = within(canvasElement);
const input = canvas.getByRole('combobox');
await waitFor(() => userEvent.hover(input));

View File

@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
<span v-if="q != null && typeof q === 'string'" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
<span v-else v-text="emoji.name"></span>
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
</li>
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</li>
</ol>
<ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="completeMfmParam(param)" @keydown="onKeydown">
<span>{{ param }}</span>
</li>
</ol>
@@ -45,12 +45,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import * as Misskey from 'misskey-js';
import sanitizeHtml from 'sanitize-html';
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
import type { EmojiDef } from '@/utility/search-emoji.js';
import contains from '@/utility/contains.js';
import { elementContains } from '@/utility/element-contains.js';
import { acct } from '@/filters/user.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@@ -63,7 +64,7 @@ import { prefer } from '@/preferences.js';
export type CompleteInfo = {
user: {
payload: any;
payload: Misskey.entities.User;
query: string | null;
},
hashtag: {
@@ -185,22 +186,27 @@ const suggests = ref<Element>();
const rootEl = useTemplateRef('rootEl');
const fetching = ref(true);
const users = ref<any[]>([]);
const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]);
const users = ref<Misskey.entities.User[]>([]);
const hashtags = ref<string[]>([]);
const emojis = ref<EmojiDef[]>([]);
const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]);
const mfmParams = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
function completeMfmParam(param: string) {
if (props.type !== 'mfmParam') throw new Error('Invalid type');
complete('mfmParam', props.q.params.toSpliced(-1, 1, param).join(','));
}
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji' || type === 'emojiComplete') {
let recents = store.s.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value);
recents = recents.filter((emoji) => emoji !== value);
recents.unshift(value as string);
store.set('recentlyUsedEmojis', recents.splice(0, 32));
}
}
@@ -249,7 +255,7 @@ function exec() {
limit: 10,
detail: false,
}).then(searchedUsers => {
users.value = searchedUsers as any[];
users.value = searchedUsers;
fetching.value = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
@@ -271,7 +277,7 @@ function exec() {
query: props.q,
limit: 30,
}).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
hashtags.value = searchedHashtags;
fetching.value = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
@@ -305,8 +311,8 @@ function exec() {
}
}
function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
function onMousedown(event: MouseEvent) {
if (!elementContains(rootEl.value, event.target as Element) && (rootEl.value !== event.target)) props.close();
}
function onKeydown(event: KeyboardEvent) {

View File

@@ -29,6 +29,6 @@ const users = ref<Misskey.entities.UserLite[]>([]);
onMounted(async () => {
users.value = await misskeyApi('users/show', {
userIds: props.userIds,
}) as unknown as Misskey.entities.UserLite[];
});
});
</script>

View File

@@ -5,7 +5,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import MkButton from './MkButton.vue';
export const Default = {

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button
v-if="!link"
ref="el" class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait, [$style.active]: active }]"
:type="type"
:name="name"
:value="value"
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
<MkA
v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait, [$style.active]: active }]"
:to="to ?? '#'"
:behavior="linkBehavior"
@mousedown="onMousedown"
@@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { nextTick, onMounted, useTemplateRef } from 'vue';
import type { MkABehavior } from '@/components/global/MkA.vue';
const props = defineProps<{
type?: 'button' | 'submit' | 'reset';
@@ -45,7 +46,7 @@ const props = defineProps<{
inline?: boolean;
link?: boolean;
to?: string;
linkBehavior?: null | 'window' | 'browser';
linkBehavior?: MkABehavior;
autofocus?: boolean;
wait?: boolean;
danger?: boolean;
@@ -58,10 +59,11 @@ const props = defineProps<{
value?: string;
disabled?: boolean;
iconOnly?: boolean;
active?: boolean;
}>();
const emit = defineEmits<{
(ev: 'click', payload: MouseEvent): void;
(ev: 'click', payload: PointerEvent): void;
}>();
const el = useTemplateRef('el');
@@ -75,11 +77,11 @@ onMounted(() => {
}
});
function distance(p, q): number {
function distance(p: { x: number; y: number }, q: { x: number; y: number }): number {
return Math.hypot(p.x - q.x, p.y - q.y);
}
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
function calcCircleScale(boxW: number, boxH: number, circleCenterX: number, circleCenterY: number): number {
const origin = { x: circleCenterX, y: circleCenterY };
const dist1 = distance({ x: 0, y: 0 }, origin);
const dist2 = distance({ x: boxW, y: 0 }, origin);
@@ -252,6 +254,10 @@ function onMousedown(evt: MouseEvent): void {
}
}
&.active {
color: var(--MI_THEME-accent) !important;
}
&:disabled {
opacity: 0.5;
}

View File

@@ -84,7 +84,7 @@ const variable = computed(() => {
}
});
const loaded = !!window[variable.value];
const loaded = !!(window as any)[variable.value];
const src = computed(() => {
switch (props.provider) {
@@ -98,7 +98,7 @@ const src = computed(() => {
const scriptId = computed(() => `script-${props.provider}`);
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
const captcha = computed<Captcha>(() => (window as any)[variable.value] ?? {} as unknown as Captcha);
watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
// 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない

View File

@@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { expect, userEvent, within } from '@storybook/test';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';

View File

@@ -3,14 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import type { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { channel } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkChannelList from './MkChannelList.vue';
import type { StoryObj } from '@storybook/vue3';
import { Paginator } from '@/utility/paginator.js';
export const Default = {
render(args) {
return {
@@ -33,10 +32,7 @@ export const Default = {
};
},
args: {
pagination: {
endpoint: 'channels/search',
limit: 10,
},
paginator: new Paginator('channels/search', {}),
},
parameters: {
chromatic: {

View File

@@ -4,13 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPagination :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.notFound }}</div>
</div>
</template>
<MkPagination :paginator="paginator">
<template #empty><MkResult type="empty"/></template>
<template #default="{ items }">
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
@@ -18,18 +13,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPagination>
</template>
<script lang="ts" setup>
import type { Paging } from '@/components/MkPagination.vue';
<script lang="ts" setup generic="P extends IPaginator">
import * as Misskey from 'misskey-js';
import type { IPaginator, ExtractorFunction } from '@/utility/paginator.js';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
pagination: Paging;
paginator: P;
noGap?: boolean;
extractor?: (item: any) => any;
extractor?: ExtractorFunction<P, Misskey.entities.Channel>;
}>(), {
extractor: (item) => item,
extractor: (item: any) => item as Misskey.entities.Channel,
});
</script>

View File

@@ -27,6 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</I18n>
</div>
<div v-if="$i != null && $i.id === channel.userId" style="color: var(--MI_THEME-warn)">
<i class="ti ti-user-star ti-fw"></i>
<span style="margin-left: 4px;">{{ i18n.ts.youAreAdmin }}</span>
</div>
</div>
</div>
<article v-if="channel.description">
@@ -48,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';

View File

@@ -51,7 +51,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import date from '@/filters/date.js';
@@ -94,8 +94,8 @@ const props = withDefaults(defineProps<{
const legendEl = useTemplateRef('legendEl');
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
const negate = arr => arr.map(x => -x);
const sum = (...arr: number[][]) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
const negate = (arr: number[]) => arr.map((x) => -x);
const colors = {
blue: '#008FFB',
@@ -108,7 +108,7 @@ const colors = {
cyan: '#00e0e0',
};
const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
const getColor = (i) => {
const getColor = (i: number) => {
return colorSets[i % colorSets.length];
};
@@ -142,7 +142,7 @@ const getDate = (ago: number) => {
return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
};
const format = (arr) => {
const format = (arr: number[]) => {
return arr.map((v, i) => ({
x: getDate(i).getTime(),
y: v,
@@ -371,7 +371,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => {
};
};
const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
const fetchNotesChart = async (type: 'local' | 'remote' | 'combined'): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span });
return {
series: [{
@@ -589,7 +589,10 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
};
const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
const host = props.args?.host;
if (host == null) return { series: [] };
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
return {
series: [{
name: 'In',
@@ -611,7 +614,10 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
};
const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
const host = props.args?.host;
if (host == null) return { series: [] };
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
return {
series: [{
name: 'Users',
@@ -626,7 +632,10 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData
};
const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
const host = props.args?.host;
if (host == null) return { series: [] };
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
return {
series: [{
name: 'Notes',
@@ -641,7 +650,10 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData
};
const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
const host = props.args?.host;
if (host == null) return { series: [] };
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
return {
series: [{
name: 'Following',
@@ -664,7 +676,10 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> =
};
const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
const host = props.args?.host;
if (host == null) return { series: [] };
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
return {
bytes: true,
series: [{
@@ -680,7 +695,10 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char
};
const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
const host = props.args?.host;
if (host == null) return { series: [] };
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
return {
series: [{
name: 'Drive files',
@@ -695,7 +713,10 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char
};
const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
const userId = props.args?.user?.id;
if (userId == null) return { series: [] };
const raw = await misskeyApiGet('charts/user/notes', { userId: userId, limit: props.limit, span: props.span });
return {
series: [...(props.args?.withoutAll ? [] : [{
name: 'All',
@@ -727,7 +748,10 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
};
const fetchPerUserPvChart = async (): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
const userId = props.args?.user?.id;
if (userId == null) return { series: [] };
const raw = await misskeyApiGet('charts/user/pv', { userId: userId, limit: props.limit, span: props.span });
return {
series: [{
name: 'Unique PV (user)',
@@ -754,7 +778,10 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => {
};
const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
const userId = props.args?.user?.id;
if (userId == null) return { series: [] };
const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span });
return {
series: [{
name: 'Local',
@@ -769,7 +796,10 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
};
const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
const userId = props.args?.user?.id;
if (userId == null) return { series: [] };
const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span });
return {
series: [{
name: 'Local',
@@ -784,7 +814,10 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
};
const fetchPerUserDriveChart = async (): Promise<typeof chartData> => {
const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
const userId = props.args?.user?.id;
if (userId == null) return { series: [] };
const raw = await misskeyApiGet('charts/user/drive', { userId: userId, limit: props.limit, span: props.span });
return {
bytes: true,
series: [{

View File

@@ -25,12 +25,12 @@ defineProps<{
showing: boolean;
x: number;
y: number;
title?: string;
title?: string | null;
series?: {
backgroundColor: string;
borderColor: string;
text: string;
}[];
}[] | null;
}>();
const emit = defineEmits<{

View File

@@ -4,7 +4,7 @@
*/
import { http, HttpResponse } from 'msw';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { chatMessage } from '../../.storybook/fakes';
import MkChatHistories from './MkChatHistories.vue';
import type { StoryObj } from '@storybook/vue3';

View File

@@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/>
<MkLoading v-if="initializing"/>
</template>

View File

@@ -2,9 +2,9 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { expect, userEvent, within } from '@storybook/test';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkClickerGame from './MkClickerGame.vue';

View File

@@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useInterval } from '@@/js/use-interval.js';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import * as os from '@/os.js';
import { useInterval } from '@@/js/use-interval.js';
import * as game from '@/utility/clicker-game.js';
import number from '@/filters/number.js';
import { claimAchievement } from '@/utility/achievements.js';
@@ -32,7 +32,7 @@ const cookies = computed(() => saveData.value?.cookies);
const cps = ref(0);
const prevCookies = ref(0);
function onClick(ev: MouseEvent) {
function onClick(ev: PointerEvent) {
const x = ev.clientX;
const y = ev.clientY;
const { dispose } = os.popup(MkPlusOneEffect, { x, y }, {

View File

@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- eslint-disable vue/no-v-html -->
<template>
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div>
<div
:class="[$style.codeBlockRoot, {
[$style.codeEditor]: codeEditor,
[$style.outerStyle]: !codeEditor && withOuterStyle,
[$style.dark]: darkMode,
[$style.light]: !darkMode,
}]" v-html="html"></div>
</template>
<script lang="ts" setup>
@@ -15,11 +21,15 @@ import type { BundledLanguage } from 'shiki/langs';
import { getHighlighter, getTheme } from '@/utility/code-highlighter.js';
import { store } from '@/store.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
code: string;
lang?: string;
codeEditor?: boolean;
}>();
withOuterStyle?: boolean;
}>(), {
codeEditor: false,
withOuterStyle: true,
});
const highlighter = await getHighlighter();
const darkMode = store.r.darkMode;
@@ -73,17 +83,13 @@ watch(() => props.lang, (to) => {
<style module lang="scss">
.codeBlockRoot :global(.shiki) {
padding: 1em;
margin: 0;
overflow: auto;
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
color: var(--shiki-fallback);
background-color: var(--shiki-fallback-bg);
& span {
color: var(--shiki-fallback);
background-color: var(--shiki-fallback-bg);
}
& pre,
@@ -92,26 +98,40 @@ watch(() => props.lang, (to) => {
}
}
.outerStyle.codeBlockRoot :global(.shiki) {
padding: 1em;
margin: 0;
border-radius: 8px;
border: 1px solid var(--MI_THEME-divider);
background-color: var(--shiki-fallback-bg);
}
.light.codeBlockRoot :global(.shiki) {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
& span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}
}
.light.outerStyle.codeBlockRoot :global(.shiki),
.light.codeEditor.codeBlockRoot :global(.shiki) {
background-color: var(--shiki-light-bg);
}
.dark.codeBlockRoot :global(.shiki) {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
& span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
}
.dark.outerStyle.codeBlockRoot :global(.shiki),
.dark.codeEditor.codeBlockRoot :global(.shiki) {
background-color: var(--shiki-dark-bg);
}
.codeBlockRoot.codeEditor {
min-width: 100%;
height: 100%;

View File

@@ -5,15 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.codeBlockRoot">
<button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy">
<button v-if="copyButton" :class="[$style.codeBlockCopyButton, { [$style.withOuterStyle]: withOuterStyle }]" class="_button" @click="copy">
<i class="ti ti-copy"></i>
</button>
<Suspense>
<template #fallback>
<MkLoading/>
<pre
class="_selectable"
:class="[$style.codeBlockFallbackRoot, {
[$style.outerStyle]: withOuterStyle,
}]"
><code :class="$style.codeBlockFallbackCode">Loading...</code></pre>
</template>
<XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/>
<pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<XCode
v-if="show && lang"
class="_selectable"
:code="code"
:lang="lang"
:withOuterStyle="withOuterStyle"
/>
<pre
v-else-if="show"
class="_selectable"
:class="[$style.codeBlockFallbackRoot, {
[$style.outerStyle]: withOuterStyle,
}]"
><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
<div :class="$style.codePlaceholderContainer">
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
@@ -26,8 +43,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import * as os from '@/os.js';
import MkLoading from '@/components/global/MkLoading.vue';
import { i18n } from '@/i18n.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { prefer } from '@/preferences.js';
@@ -36,10 +51,12 @@ const props = withDefaults(defineProps<{
code: string;
forceShow?: boolean;
copyButton?: boolean;
withOuterStyle?: boolean;
lang?: string;
}>(), {
copyButton: true,
forceShow: false,
withOuterStyle: true,
});
const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code);
@@ -58,10 +75,16 @@ function copy() {
.codeBlockCopyButton {
position: absolute;
top: 8px;
right: 8px;
opacity: 0.5;
top: 0;
right: 0;
&.withOuterStyle {
top: 8px;
right: 8px;
}
&:hover {
opacity: 0.8;
}
@@ -70,11 +93,17 @@ function copy() {
.codeBlockFallbackRoot {
display: block;
overflow-wrap: anywhere;
padding: 1em;
margin: 0;
overflow: auto;
}
.outerStyle.codeBlockFallbackRoot {
background: var(--MI_THEME-bg);
padding: 1em;
margin: .5em 0;
border-radius: 8px;
border: 1px solid var(--MI_THEME-divider);
}
.codeBlockFallbackCode {
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
}

View File

@@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import type { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import MkCodeEditor from './MkCodeEditor.vue';
const code = `for (let i, 100) {
<: if (i % 15 == 0) "FizzBuzz"

View File

@@ -40,7 +40,7 @@ import XCode from '@/components/MkCode.core.vue';
const props = withDefaults(defineProps<{
modelValue: string | null;
lang: string;
lang?: string;
required?: boolean;
readonly?: boolean;
disabled?: boolean;
@@ -51,7 +51,7 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void;
(ev: 'change', _ev: InputEvent): void;
(ev: 'keydown', _ev: KeyboardEvent): void;
(ev: 'enter'): void;
(ev: 'update:modelValue', value: string): void;
@@ -63,15 +63,17 @@ const focused = ref(false);
const changed = ref(false);
const inputEl = useTemplateRef('inputEl');
const focus = () => inputEl.value?.focus();
function focus() {
inputEl.value?.focus();
}
const onInput = (ev) => {
v.value = ev.target?.value ?? v.value;
function onInput(ev: InputEvent) {
v.value = (inputEl.value?.value) ?? '';
changed.value = true;
emit('change', ev);
};
}
const onKeydown = (ev: KeyboardEvent) => {
function onKeydown(ev: KeyboardEvent) {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
emit('keydown', ev);
@@ -102,12 +104,12 @@ const onKeydown = (ev: KeyboardEvent) => {
});
ev.preventDefault();
}
};
}
const updated = () => {
function updated() {
changed.value = false;
emit('update:modelValue', v.value);
};
}
const debouncedUpdated = debounce(1000, updated);

View File

@@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import type { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import MkColorInput from './MkColorInput.vue';
export const Default = {
render(args) {

View File

@@ -48,7 +48,6 @@ const props = withDefaults(defineProps<{
thin?: boolean;
naked?: boolean;
foldable?: boolean;
onUnfold?: () => boolean; // return false to prevent unfolding
scrollable?: boolean;
expanded?: boolean;
maxHeight?: number | null;
@@ -103,8 +102,6 @@ const omitObserver = new ResizeObserver((entries, observer) => {
});
function showMore() {
if (props.onUnfold && !props.onUnfold()) return;
ignoreOmit.value = true;
omitted.value = false;
}
@@ -154,6 +151,10 @@ onUnmounted(() => {
&.naked {
background: transparent !important;
box-shadow: none !important;
> .content {
background: transparent !important;
}
}
&.scrollable {

View File

@@ -3,11 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import type { StoryObj } from '@storybook/vue3';
import { userEvent, within } from '@storybook/test';
import MkContextMenu from './MkContextMenu.vue';
import type { StoryObj } from '@storybook/vue3';
import * as os from '@/os.js';
export const Empty = {
render(args) {
@@ -25,7 +23,7 @@ export const Empty = {
},
},
methods: {
onContextmenu(ev: MouseEvent) {
onContextmenu(ev: PointerEvent) {
os.contextMenu(args.items, ev);
},
},

View File

@@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue';
import MkMenu from './MkMenu.vue';
import type { MenuItem } from '@/types/menu.js';
import contains from '@/utility/contains.js';
import { elementContains } from '@/utility/element-contains.js';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
const props = defineProps<{
items: MenuItem[];
ev: MouseEvent;
ev: PointerEvent;
}>();
const emit = defineEmits<{
@@ -75,8 +75,8 @@ onBeforeUnmount(() => {
window.document.body.removeEventListener('mousedown', onMousedown);
});
function onMousedown(evt: Event) {
if (!contains(rootEl.value, evt.target) && (rootEl.value !== evt.target)) emit('closed');
function onMousedown(evt: MouseEvent) {
if (!elementContains(rootEl.value, evt.target as Element) && (rootEl.value !== evt.target)) emit('closed');
}
</script>

View File

@@ -4,7 +4,7 @@
*/
import { HttpResponse, http } from 'msw';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { file } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkCropperDialog from './MkCropperDialog.vue';
@@ -38,7 +38,7 @@ export const Default = {
};
},
args: {
file: file(),
imageFile: new File([], 'image.webp', { type: 'image/webp' }),
aspectRatio: NaN,
},
parameters: {

View File

@@ -15,132 +15,121 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.cropImage }}</template>
<template #default="{ width, height }">
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
<Transition name="fade">
<div v-if="loading" class="loading">
<MkLoading/>
</div>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
<div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
<Transition name="fade">
<div v-if="loading" class="loading">
<MkLoading/>
</div>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div>
</template>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { onMounted, useTemplateRef, ref } from 'vue';
<script lang="ts" setup generic="F extends File | Blob">
import { onMounted, useTemplateRef, ref, onUnmounted } from 'vue';
import * as Misskey from 'misskey-js';
import Cropper from 'cropperjs';
import tinycolor from 'tinycolor2';
import { apiUrl } from '@@/js/config.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
imageFile: F;
aspectRatio: number | null;
uploadFolder?: string | null;
}>();
const emit = defineEmits<{
(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
(ev: 'ok', cropped: F): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const props = defineProps<{
file: Misskey.entities.DriveFile;
aspectRatio: number;
uploadFolder?: string | null;
}>();
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
const imgUrl = URL.createObjectURL(props.imageFile);
const dialogEl = useTemplateRef('dialogEl');
const imgEl = useTemplateRef('imgEl');
let cropper: Cropper | null = null;
const loading = ref(true);
const ok = async () => {
const promise = new Promise<Misskey.entities.DriveFile>(async (res) => {
const croppedImage = await cropper?.getCropperImage();
const croppedSection = await cropper?.getCropperSelection();
async function ok() {
const promise = new Promise<Blob>(async (res) => {
if (cropper == null) throw new Error('Cropper is not initialized');
const croppedImage = await cropper.getCropperImage()!;
const croppedSection = await cropper.getCropperSelection()!;
// 拡大率を計算し、(ほぼ)元の大きさに戻す
const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth;
const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate;
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
croppedCanvas?.toBlob(blob => {
const croppedCanvas = await croppedSection.$toCanvas({ width: widthToRender });
croppedCanvas.toBlob(blob => {
if (!blob) return;
const formData = new FormData();
formData.append('file', blob);
formData.append('name', `cropped_${props.file.name}`);
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
if (props.file.comment) { formData.append('comment', props.file.comment);}
formData.append('i', $i!.token);
if (props.uploadFolder) {
formData.append('folderId', props.uploadFolder);
} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
formData.append('folderId', prefer.s.uploadFolder);
}
window.fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: formData,
})
.then(response => response.json())
.then(f => {
res(f);
});
res(blob);
});
});
os.promiseDialog(promise);
const f = await promise;
let finalFile: F;
if (props.imageFile instanceof File) {
finalFile = new File([f], props.imageFile.name, { type: f.type }) as F;
} else {
finalFile = f as F;
}
emit('ok', f);
dialogEl.value!.close();
};
emit('ok', finalFile);
if (dialogEl.value != null) dialogEl.value.close();
}
const cancel = () => {
function cancel() {
emit('cancel');
dialogEl.value!.close();
};
if (dialogEl.value != null) dialogEl.value.close();
}
const onImageLoad = () => {
function onImageLoad() {
loading.value = false;
if (cropper) {
cropper.getCropperImage()!.$center('contain');
cropper.getCropperSelection()!.$center();
}
};
}
onMounted(() => {
cropper = new Cropper(imgEl.value!, {
if (imgEl.value == null) return; // TSを黙らすため
cropper = new Cropper(imgEl.value, {
});
const computedStyle = getComputedStyle(window.document.documentElement);
const selection = cropper.getCropperSelection()!;
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
selection.aspectRatio = props.aspectRatio;
selection.initialAspectRatio = props.aspectRatio;
if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio;
selection.initialAspectRatio = props.aspectRatio ?? 1;
selection.outlined = true;
window.setTimeout(() => {
cropper!.getCropperImage()!.$center('contain');
if (cropper == null) return;
cropper.getCropperImage()!.$center('contain');
selection.$center();
}, 100);
// モーダルオープンアニメーションが終わったあとで再度調整
window.setTimeout(() => {
cropper!.getCropperImage()!.$center('contain');
if (cropper == null) return;
cropper.getCropperImage()!.$center('contain');
selection.$center();
}, 500);
});
onUnmounted(() => {
URL.revokeObjectURL(imgUrl);
});
</script>
<style lang="scss" scoped>

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="emit('closed')">
<template #header>:{{ emoji.name }}:</template>
<template #default>
<MkSpacer>
<div class="_spacer">
<div style="display: flex; flex-direction: column; gap: 1em;">
<div :class="$style.emojiImgWrapper">
<MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkKeyValue>
</div>
</MkSpacer>
</div>
</template>
</MkModalWindow>
</template>

View File

@@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable import/no-default-export */
import type { StoryObj } from '@storybook/vue3';
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { expect, userEvent, within } from '@storybook/test';
import { file } from '../../.storybook/fakes.js';
import MkCwButton from './MkCwButton.vue';

View File

@@ -1,256 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<!-- TODO: 親からスタイルを当てにくいことや実装がトリッキーなことを鑑み廃止または使用の縮小(timeline-date-separate.tsを使う) -->
<script lang="ts">
import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
import type { PropType } from 'vue';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { getDateText } from '@/utility/timeline-date-separate.js';
export default defineComponent({
props: {
items: {
type: Array as PropType<MisskeyEntity[]>,
required: true,
},
direction: {
type: String,
required: false,
default: 'down',
},
reversed: {
type: Boolean,
required: false,
default: false,
},
noGap: {
type: Boolean,
required: false,
default: false,
},
ad: {
type: Boolean,
required: false,
default: false,
},
},
setup(props, { slots, expose }) {
const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
if (props.items.length === 0) return;
const renderChildrenImpl = () => props.items.map((item, i) => {
if (!slots || !slots.default) return;
const el = slots.default({
item: item,
})[0];
if (el.key == null && item.id) el.key = item.id;
const date = new Date(item.createdAt);
const nextDate = props.items[i + 1] ? new Date(props.items[i + 1].createdAt) : null;
if (
i !== props.items.length - 1 &&
nextDate != null && (
date.getFullYear() !== nextDate.getFullYear() ||
date.getMonth() !== nextDate.getMonth() ||
date.getDate() !== nextDate.getDate()
)
) {
const separator = h('div', {
class: $style['separator'],
key: item.id + ':separator',
}, h('p', {
class: $style['date'],
}, [
h('span', {
class: $style['date-1'],
}, [
h('i', {
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
}),
getDateText(date),
]),
h('span', {
class: $style['date-2'],
}, [
getDateText(nextDate),
h('i', {
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
}),
]),
]));
return [el, separator];
} else {
if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) {
return [h('div', {
key: item.id + ':ad',
class: $style['ad-wrapper'],
}, [h(MkAd, {
prefer: ['horizontal', 'horizontal-big'],
})]), el];
} else {
return el;
}
}
});
const renderChildren = () => {
const children = renderChildrenImpl();
if (isDebuggerEnabled(6864)) {
const nodes = children.flatMap((node) => node ?? []);
const keys = new Set(nodes.map((node) => node.key));
if (keys.size !== nodes.length) {
const id = crypto.randomUUID();
const instances = stackTraceInstances();
os.toast(instances.reduce((a, c) => `${a} at ${c.type.name}`, `[DEBUG_6864 (${id})]: ${nodes.length - keys.size} duplicated keys found`));
console.warn({ id, debugId: 6864, stack: instances });
}
}
return children;
};
function onBeforeLeave(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.top = `${el.offsetTop}px`;
el.style.left = `${el.offsetLeft}px`;
}
function onLeaveCancelled(el: Element) {
if (!(el instanceof HTMLElement)) return;
el.style.top = '';
el.style.left = '';
}
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const classes = {
[$style['date-separated-list']]: true,
[$style['date-separated-list-nogap']]: props.noGap,
[$style['reversed']]: props.reversed,
[$style['direction-down']]: props.direction === 'down',
[$style['direction-up']]: props.direction === 'up',
};
return () => prefer.s.animation ? h(TransitionGroup, {
class: classes,
name: 'list',
tag: 'div',
onBeforeLeave,
onLeaveCancelled,
}, { default: renderChildren }) : h('div', {
class: classes,
}, { default: renderChildren });
},
});
</script>
<style lang="scss" module>
.date-separated-list {
container-type: inline-size;
&:global {
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-enter-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> *:empty {
display: none;
}
}
&:not(.date-separated-list-nogap) > *:not(:last-child) {
margin-bottom: var(--MI-margin);
}
}
.date-separated-list-nogap {
> * {
margin: 0 !important;
border: none;
border-radius: 0;
box-shadow: none;
&:not(:last-child) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
}
}
.direction-up {
&:global {
> .list-enter-from,
> .list-leave-to {
opacity: 0;
transform: translateY(64px);
}
}
}
.direction-down {
&:global {
> .list-enter-from,
> .list-leave-to {
opacity: 0;
transform: translateY(-64px);
}
}
}
.reversed {
display: flex;
flex-direction: column-reverse;
}
.separator {
text-align: center;
}
.date {
display: inline-block;
position: relative;
margin: 0;
padding: 0 16px;
line-height: 32px;
text-align: center;
font-size: 12px;
color: var(--MI_THEME-dateLabelFg);
}
.date-1 {
margin-right: 8px;
}
.date-1-icon {
margin-right: 8px;
}
.date-2 {
margin-left: 8px;
}
.date-2-icon {
margin-left: 8px;
}
.ad-wrapper {
padding: 8px;
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
}
</style>

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import type { StoryObj } from '@storybook/vue3';
import { i18n } from '@/i18n.js';

View File

@@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div
v-else-if="!input && !select"
:class="[$style.icon, {
[$style.type_success]: type === 'success',
[$style.type_error]: type === 'error',
[$style.type_warning]: type === 'warning',
[$style.type_info]: type === 'info',
}]"
:class="[$style.icon]"
>
<i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i>
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i>
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i>
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i>
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
<MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/>
<MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/>
<MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/>
<MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/>
<MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/>
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
</div>
<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
@@ -30,20 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"></span>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"></span>
</template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items">
<template v-for="item in select.items">
<optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle">
<option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option>
</optgroup>
<option v-else :value="item.value">{{ item.text }}</option>
</template>
</template>
</MkSelect>
<MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
@@ -55,12 +41,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkModal>
</template>
<script lang="ts">
export type Result = string | number | true | null;
export type MkDialogReturnType<T = Result> = { canceled: true, result: undefined } | { canceled: false, result: T };
</script>
<script lang="ts" setup>
import { ref, useTemplateRef, computed } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { OptionValue } from '@/types/option-value.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { i18n } from '@/i18n.js';
type Input = {
@@ -72,21 +66,11 @@ type Input = {
maxLength?: number;
};
type SelectItem = {
value: any;
text: string;
};
type Select = {
items: (SelectItem | {
sectionTitle: string;
items: SelectItem[];
})[];
default: string | null;
items: MkSelectItem[];
default: OptionValue | null;
};
type Result = string | number | true | null;
const props = withDefaults(defineProps<{
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
title?: string;
@@ -113,14 +97,13 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
(ev: 'done', v: MkDialogReturnType): void;
(ev: 'closed'): void;
}>();
const modal = useTemplateRef('modal');
const inputValue = ref<string | number | null>(props.input?.default ?? null);
const selectedValue = ref(props.select?.default ?? null);
const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
if (props.input) {
@@ -139,12 +122,20 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
return null;
});
const {
def: selectDef,
model: selectedValue,
} = useMkSelect({
items: computed(() => props.select?.items ?? []),
initialValue: props.select?.default ?? null,
});
// overload function を使いたいので lint エラーを無視する
function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
emit('done', { canceled, result } as MkDialogReturnType);
modal.value?.close();
}
@@ -202,22 +193,6 @@ function onInputKeydown(evt: KeyboardEvent) {
margin: 0 auto;
}
.type_info {
color: #55c4dd;
}
.type_success {
color: var(--MI_THEME-success);
}
.type_error {
color: var(--MI_THEME-error);
}
.type_warning {
color: var(--MI_THEME-warn);
}
.title {
margin: 0 0 8px 0;
font-weight: bold;

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
borderWidth ? { borderWidth: borderWidth } : {},
borderColor ? { borderColor: borderColor } : {},
]"
/>
></div>
</template>
<script setup lang="ts">

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { onBeforeUnmount } from 'vue';
import MkDonation from './MkDonation.vue';

View File

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

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import MkDrive_file from './MkDrive.file.vue';
import { file } from '../../.storybook/fakes.js';

View File

@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.isSelected]: isSelected }]"
draggable="true"
:title="title"
@click="onClick"
@contextmenu.stop="onContextmenu"
@dragstart="onDragstart"
@dragend="onDragend"
@@ -46,24 +45,18 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
import { deviceKind } from '@/utility/device-kind.js';
import { useRouter } from '@/router.js';
const router = useRouter();
import { setDragData } from '@/drag-and-drop.js';
const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile;
folder: Misskey.entities.DriveFolder | null;
isSelected?: boolean;
selectMode?: boolean;
}>(), {
isSelected: false,
selectMode: false,
});
const emit = defineEmits<{
(ev: 'chosen', r: Misskey.entities.DriveFile): void;
(ev: 'dragstart'): void;
(ev: 'dragstart', dragEvent: DragEvent): void;
(ev: 'dragend'): void;
}>();
@@ -71,30 +64,18 @@ const isDragging = ref(false);
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
function onClick(ev: MouseEvent) {
if (props.selectMode) {
emit('chosen', props.file);
} else {
if (deviceKind === 'desktop') {
router.push(`/my/drive/file/${props.file.id}`);
} else {
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
}
}
function onContextmenu(ev: MouseEvent) {
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
}
function onDragstart(ev: DragEvent) {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
setDragData(ev, 'driveFiles', [props.file]);
}
isDragging.value = true;
emit('dragstart');
emit('dragstart', ev);
}
function onDragend() {
@@ -114,7 +95,7 @@ function onDragend() {
&:hover {
background: rgba(#000, 0.05);
> .label {
.label {
&::before,
&::after {
background: #0b65a5;
@@ -132,7 +113,7 @@ function onDragend() {
&:active {
background: rgba(#000, 0.1);
> .label {
.label {
&::before,
&::after {
background: #0b588c;
@@ -158,19 +139,19 @@ function onDragend() {
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
}
> .label {
.label {
&::before,
&::after {
display: none;
}
}
> .name {
color: #fff;
.name {
color: var(--MI_THEME-fgOnAccent);
}
> .thumbnail {
color: #fff;
.thumbnail {
color: var(--MI_THEME-fgOnAccent);
}
}
}
@@ -240,8 +221,9 @@ function onDragend() {
.name {
display: block;
margin: 4px 0 0 0;
font-size: 0.8em;
margin: 8px 0 0 0;
padding: 0 2px;
font-size: 82%;
text-align: center;
word-break: break-all;
color: var(--MI_THEME-fg);

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { http, HttpResponse } from 'msw';
import * as Misskey from 'misskey-js';

View File

@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.draghover]: draghover }]"
draggable="true"
:title="title"
@click="onClick"
@contextmenu.stop="onContextmenu"
@mouseover="onMouseover"
@mouseout="onMouseout"
@@ -19,16 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only
@dragstart="onDragstart"
@dragend="onDragend"
>
<p :class="$style.name">
<template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
{{ folder.name }}
</p>
<p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
<svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none">
<path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/>
</svg>
<div :class="$style.name">{{ folder.name }}</div>
<div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
{{ i18n.ts.uploadFolder }}
</p>
</div>
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
<div :class="[$style.checkbox, { [$style.checked]: isSelected, 'ti ti-check': isSelected }]"></div>
</button>
</div>
</template>
@@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/utility/achievements.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
import { selectDriveFolder } from '@/utility/drive.js';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
@@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
(ev: 'unchose', v: Misskey.entities.DriveFolder): void;
(ev: 'move', v: Misskey.entities.DriveFolder): void;
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
(ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder): void;
(ev: 'dragstart'): void;
(ev: 'dragend'): void;
}>();
@@ -78,10 +76,6 @@ function checkboxClicked() {
}
}
function onClick() {
emit('move', props.folder);
}
function onMouseover() {
hover.value = true;
}
@@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) {
}
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
@@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) {
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) {
emit('upload', file, props.folder);
}
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
emit('removeFile', file.id);
misskeyApi('drive/files/update', {
fileId: file.id,
folderId: props.folder.id,
});
{
const droppedData = getDragData(ev, 'driveFiles');
if (droppedData != null) {
misskeyApi('drive/files/move-bulk', {
fileIds: droppedData.map(f => f.id),
folderId: props.folder.id,
}).then(() => {
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
...x,
folderId: props.folder.id,
folder: props.folder,
})));
});
}
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder !== '') {
const folder = JSON.parse(driveFolder);
{
const droppedData = getDragData(ev, 'driveFolders');
if (droppedData != null) {
const droppedFolder = droppedData[0];
// 移動先が自分自身ならreject
if (folder.id === props.folder.id) return;
// 移動先が自分自身ならreject
if (droppedFolder.id === props.folder.id) return;
emit('removeFolder', folder.id);
misskeyApi('drive/folders/update', {
folderId: folder.id,
parentId: props.folder.id,
}).then(() => {
// noop
}).catch(err => {
switch (err.code) {
case 'RECURSIVE_NESTING':
claimAchievement('driveFolderCircularReference');
os.alert({
type: 'error',
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
});
misskeyApi('drive/folders/update', {
folderId: droppedFolder.id,
parentId: props.folder.id,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
...x,
parentId: props.folder.id,
parent: props.folder,
})));
}).catch(err => {
switch (err.code) {
case 'RECURSIVE_NESTING':
claimAchievement('driveFolderCircularReference');
os.alert({
type: 'error',
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
});
}
}
//#endregion
}
@@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) {
if (!ev.dataTransfer) return;
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
setDragData(ev, 'driveFolders', [props.folder]);
isDragging.value = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
@@ -211,10 +211,6 @@ function onDragend() {
emit('dragend');
}
function go() {
emit('move', props.folder);
}
function rename() {
os.inputText({
title: i18n.ts.renameFolder,
@@ -225,17 +221,28 @@ function rename() {
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
name: name,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [{
...props.folder,
name: name,
}]);
});
});
}
function move() {
os.selectDriveFolder(false).then(folder => {
if (folder[0] && folder[0].id === props.folder.id) return;
selectDriveFolder(null).then(({ canceled, folders }) => {
if (canceled || (folders[0] && folders[0].id === props.folder.id)) return;
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
parentId: folder[0] ? folder[0].id : null,
parentId: folders[0] ? folders[0].id : null,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [{
...props.folder,
parentId: folders[0] ? folders[0].id : null,
parent: folders[0] ?? null,
}]);
});
});
}
@@ -247,6 +254,7 @@ function deleteFolder() {
if (prefer.s.uploadFolder === props.folder.id) {
prefer.commit('uploadFolder', null);
}
globalEvents.emit('driveFoldersDeleted', [props.folder]);
}).catch(err => {
switch (err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
@@ -269,13 +277,13 @@ function setAsUploadFolder() {
prefer.commit('uploadFolder', props.folder.id);
}
function onContextmenu(ev: MouseEvent) {
function onContextmenu(ev: PointerEvent) {
let menu: MenuItem[];
menu = [{
text: i18n.ts.openInWindow,
icon: 'ti ti-app-window',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveWindow.vue').then(x => x.default), {
initialFolder: props.folder,
}, {
closed: () => dispose(),
@@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) {
<style lang="scss" module>
.root {
position: relative;
padding: 8px;
height: 64px;
background: var(--MI_THEME-driveFolderBg);
border-radius: 4px;
height: 90px;
padding: 24px 16px;
box-sizing: border-box;
cursor: pointer;
&.draghover {
@@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) {
}
}
.shape {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.checkboxWrapper {
position: absolute;
border-radius: 50%;
@@ -353,16 +368,14 @@ function onContextmenu(ev: MouseEvent) {
border-color: var(--MI_THEME-accent);
background: var(--MI_THEME-accent);
&::after {
content: "\ea5e";
font-family: 'tabler-icons';
&::before {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 12px;
line-height: 22px;
line-height: 18px;
}
}
}
@@ -373,7 +386,6 @@ function onContextmenu(ev: MouseEvent) {
}
.name {
margin: 0;
font-size: 0.9em;
}
@@ -384,7 +396,6 @@ function onContextmenu(ev: MouseEvent) {
}
.upload {
margin: 4px 4px;
font-size: 0.8em;
text-align: right;
}

View File

@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
:class="[$style.root, { [$style.draghover]: draghover }]"
@click="onClick"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@@ -22,6 +21,8 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const props = defineProps<{
folder?: Misskey.entities.DriveFolder;
@@ -29,27 +30,11 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(ev: 'move', v?: Misskey.entities.DriveFolder): void;
(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
(ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void;
}>();
const hover = ref(false);
const draghover = ref(false);
function onClick() {
emit('move', props.folder);
}
function onMouseover() {
hover.value = true;
}
function onMouseout() {
hover.value = false;
}
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
@@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) {
}
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
@@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) {
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) {
emit('upload', file, props.folder);
}
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
emit('removeFile', file.id);
misskeyApi('drive/files/update', {
fileId: file.id,
folderId: props.folder ? props.folder.id : null,
});
{
const droppedData = getDragData(ev, 'driveFiles');
if (droppedData != null) {
misskeyApi('drive/files/move-bulk', {
fileIds: droppedData.map(f => f.id),
folderId: props.folder ? props.folder.id : null,
}).then(() => {
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
...x,
folderId: props.folder ? props.folder.id : null,
folder: props.folder ?? null,
})));
});
}
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder !== '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (props.folder && folder.id === props.folder.id) return;
emit('removeFolder', folder.id);
misskeyApi('drive/folders/update', {
folderId: folder.id,
parentId: props.folder ? props.folder.id : null,
});
{
const droppedData = getDragData(ev, 'driveFolders');
if (droppedData != null) {
const droppedFolder = droppedData[0];
// 移動先が自分自身ならreject
if (props.folder && droppedFolder.id === props.folder.id) return;
misskeyApi('drive/folders/update', {
folderId: droppedFolder.id,
parentId: props.folder ? props.folder.id : null,
}).then(() => {
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
...x,
parentId: props.folder ? props.folder.id : null,
parent: props.folder ?? null,
})));
});
}
}
//#endregion
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import type { StoryObj } from '@storybook/vue3';
import { http, HttpResponse } from 'msw';
import * as Misskey from 'misskey-js';

File diff suppressed because it is too large Load Diff

View File

@@ -3,5 +3,5 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue';
void MkDriveSelectDialog;

View File

@@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only
:width="800"
:height="500"
:withOkButton="true"
:okButtonDisabled="(type === 'file') && (selected.length === 0)"
:okButtonDisabled="selected.length === 0"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="emit('closed')"
>
<template #header>
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
{{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
</template>
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
<MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import XDrive from '@/components/MkDrive.vue';
import MkDrive from '@/components/MkDrive.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import number from '@/filters/number.js';
import { i18n } from '@/i18n.js';
withDefaults(defineProps<{
type?: 'file' | 'folder';
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
multiple: boolean;
}>(), {
type: 'file',
});
const emit = defineEmits<{
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
const selected = ref<Misskey.entities.DriveFile[]>([]);
function ok() {
emit('done', selected.value);
@@ -57,7 +55,7 @@ function cancel() {
dialog.value?.close();
}
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
function onChangeSelection(v: Misskey.entities.DriveFile[]) {
selected.value = v;
}
</script>

View File

@@ -11,15 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.large]: large,
}]"
>
<ImgWithBlurhash
v-if="isThumbnailAvailable"
<MkImgWithBlurhash
v-if="isThumbnailAvailable && prefer.s.enableHighQualityImagePlaceholders"
:hash="file.blurhash"
:src="file.thumbnailUrl"
:alt="file.name"
:title="file.name"
:class="$style.thumbnail"
:cover="fit !== 'contain'"
:forceBlurhash="forceBlurhash"
/>
<img
v-else-if="isThumbnailAvailable && file.thumbnailUrl != null"
:src="file.thumbnailUrl"
:alt="file.name"
:title="file.name"
:class="$style.thumbnail"
:style="{ objectFit: fit }"
/>
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
@@ -36,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
file: Misskey.entities.DriveFile;
@@ -115,4 +125,8 @@ const isThumbnailAvailable = computed(() => {
.large .icon {
font-size: 40px;
}
.thumbnail {
width: 100%;
}
</style>

View File

@@ -0,0 +1,63 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="800"
:height="500"
:withOkButton="true"
:okButtonDisabled="selected.length === 0"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="emit('closed')"
>
<template #header>
{{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }}
<span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
</template>
<MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkDrive from '@/components/MkDrive.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
withDefaults(defineProps<{
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
multiple?: boolean;
}>(), {
initialFolder: null,
multiple: false,
});
const emit = defineEmits<{
(ev: 'done', r?: (Misskey.entities.DriveFolder | null)[]): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
const selected = ref<(Misskey.entities.DriveFolder | null)[]>([]);
function ok() {
emit('done', selected.value);
dialog.value?.close();
}
function cancel() {
emit('done');
dialog.value?.close();
}
function onChangeSelection(v: (Misskey.entities.DriveFolder | null)[]) {
selected.value = v;
}
</script>

View File

@@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
{{ i18n.ts.drive }}
</template>
<XDrive :initialFolder="initialFolder"/>
<MkDrive :initialFolder="initialFolder"/>
</MkWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import XDrive from '@/components/MkDrive.vue';
import MkDrive from '@/components/MkDrive.vue';
import MkWindow from '@/components/MkWindow.vue';
import { i18n } from '@/i18n.js';
defineProps<{
initialFolder?: Misskey.entities.DriveFolder;
initialFolder?: Misskey.entities.DriveFolder | null;
}>();
const emit = defineEmits<{

View File

@@ -23,11 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div
:class="$style.embedCodeGenPreviewRoot"
>
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
<template #preview>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
@@ -45,30 +42,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</div>
<div :class="$style.embedCodeGenSettings" class="_gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode">
<template #label>{{ i18n.ts.theme }}</template>
<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
<option value="light">{{ i18n.ts.light }}</option>
<option value="dark">{{ i18n.ts.dark }}</option>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</template>
<template #controls>
<div class="_spacer _gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode" :items="colorModeDef">
<template #label>{{ i18n.ts.theme }}</template>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
<div class="_gaps_s">
@@ -94,17 +90,15 @@ import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
@@ -160,9 +154,20 @@ const embedPreviewUrl = computed(() => {
const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity));
const header = ref(props.params?.header ?? true);
const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500);
const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500);
const {
model: colorMode,
def: colorModeDef,
} = useMkSelect({
items: [
{ value: 'auto', label: i18n.ts.syncDeviceDarkMode },
{ value: 'light', label: i18n.ts.light },
{ value: 'dark', label: i18n.ts.dark },
],
initialValue: props.params?.colorMode ?? 'auto',
});
const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
const rounded = ref(props.params?.rounded ?? true);
const border = ref(props.params?.border ?? true);
@@ -297,20 +302,6 @@ onUnmounted(() => {
height: 100%;
}
.embedCodeGenInputRoot {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.embedCodeGenPreviewRoot {
position: relative;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
cursor: not-allowed;
}
.embedCodeGenPreviewWrapper {
display: flex;
flex-direction: column;
@@ -358,11 +349,6 @@ onUnmounted(() => {
color-scheme: light dark;
}
.embedCodeGenSettings {
padding: 24px;
overflow-y: scroll;
}
.embedCodeGenResultRoot {
box-sizing: border-box;
padding: 24px;
@@ -403,11 +389,4 @@ onUnmounted(() => {
.embedCodeGenResultButtons {
margin: 0 auto;
}
@container (max-width: 800px) {
.embedCodeGenInputRoot {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { Ref } from 'vue';
import { getEmojiName } from '@@/js/emojilist.js';
import type { Ref } from 'vue';
import type { CustomEmojiFolderTree } from '@@/js/emojilist.js';
import { i18n } from '@/i18n.js';
import { customEmojis } from '@/custom-emojis.js';
@@ -78,7 +78,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(ev: 'chosen', v: string, event: MouseEvent): void;
(ev: 'chosen', v: string, event: PointerEvent): void;
}>();
const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props.emojis.value);
@@ -86,13 +86,13 @@ const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props
const shown = ref(!!props.initialShown);
/** @see MkEmojiPicker.vue */
function computeButtonTitle(ev: MouseEvent): void {
function computeButtonTitle(ev: PointerEvent): void {
const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji);
}
function nestedChosen(emoji: string, ev: MouseEvent) {
function nestedChosen(emoji: string, ev: PointerEvent) {
emit('chosen', emoji, ev);
}
</script>

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { action } from 'storybook/actions';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import type { StoryObj } from '@storybook/vue3';
import { i18n } from '@/i18n.js';

View File

@@ -64,6 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
</button>
<button v-tooltip="i18n.ts.settings" class="_button config" @click="settings"><i class="ti ti-settings"></i></button>
</div>
</section>
@@ -139,6 +140,10 @@ import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-e
import { $i } from '@/i.js';
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
import { prefer } from '@/preferences.js';
import { useRouter } from '@/router.js';
import { haptic } from '@/utility/haptic.js';
const router = useRouter();
const props = withDefaults(defineProps<{
showPinned?: boolean;
@@ -147,7 +152,7 @@ const props = withDefaults(defineProps<{
asDrawer?: boolean;
asWindow?: boolean;
asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう
targetNote?: Misskey.entities.Note;
targetNote?: Misskey.entities.Note | null;
}>(), {
showPinned: true,
});
@@ -321,7 +326,7 @@ watch(q, () => {
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
for (const emoji of emojis) {
if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) {
if (keywords.every(keyword => index[emoji.char]?.some(k => k.includes(keyword)))) {
matches.add(emoji);
if (matches.size >= max) break;
}
@@ -338,7 +343,7 @@ watch(q, () => {
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
for (const emoji of emojis) {
if (index[emoji.char].some(k => k.startsWith(newQ))) {
if (index[emoji.char]?.some(k => k.startsWith(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
@@ -355,7 +360,7 @@ watch(q, () => {
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
for (const emoji of emojis) {
if (index[emoji.char].some(k => k.includes(newQ))) {
if (index[emoji.char]?.some(k => k.includes(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
@@ -407,13 +412,13 @@ function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeE
}
/** @see MkEmojiPicker.section.vue */
function computeButtonTitle(ev: MouseEvent): void {
function computeButtonTitle(ev: PointerEvent): void {
const elm = ev.target as HTMLElement;
const emoji = elm.dataset.emoji as string;
elm.title = getEmojiName(emoji);
}
function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) {
function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: PointerEvent) {
const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
if (el && prefer.s.animation) {
const rect = el.getBoundingClientRect();
@@ -427,6 +432,8 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef,
const key = getKey(emoji);
emit('chosen', key);
haptic();
// 最近使った絵文字更新
if (!pinned.value?.includes(key)) {
let recents = store.s.recentlyUsedEmojis;
@@ -489,6 +496,11 @@ function done(query?: string): boolean | void {
}
}
function settings() {
emit('esc');
router.push('/settings/emoji-palette');
}
onMounted(() => {
focus();
});
@@ -518,47 +530,53 @@ defineExpose({
--eachSize: 50px;
}
&.s4 {
--eachSize: 55px;
}
&.s5 {
--eachSize: 60px;
}
&.w1 {
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr;
--columns: 5;
}
&.w2 {
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 6;
}
&.w3 {
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 7;
}
&.w4 {
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 8;
}
&.w5 {
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
--columns: 9;
}
&.h1 {
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
--rows: 4;
}
&.h2 {
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
--rows: 6;
}
&.h3 {
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
--rows: 8;
}
&.h4 {
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
--rows: 10;
}
width: calc((var(--eachSize) * var(--columns)) + (#{$pad} * 2));
height: calc((var(--eachSize) * var(--rows)) + (#{$pad} * 2));
&.asDrawer {
width: 100% !important;
@@ -573,9 +591,17 @@ defineExpose({
> .body {
display: grid;
grid-template-columns: var(--columns);
grid-template-columns: repeat(var(--columns), 1fr);
font-size: 30px;
> .config {
aspect-ratio: 1 / 1;
width: auto;
height: auto;
min-width: 0;
font-size: 14px;
}
> .item {
aspect-ratio: 1 / 1;
width: auto;
@@ -607,7 +633,7 @@ defineExpose({
::v-deep(section) {
> .body {
display: grid;
grid-template-columns: var(--columns);
grid-template-columns: repeat(var(--columns), 1fr);
font-size: 30px;
> .item {
@@ -675,13 +701,8 @@ defineExpose({
height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
> .group {
&:not(.index) {
padding: 4px 0 8px 0;
@@ -720,6 +741,15 @@ defineExpose({
position: relative;
padding: $pad;
> .config {
position: relative;
padding: 0 3px;
width: var(--eachSize);
height: var(--eachSize);
contain: strict;
opacity: 0.5;
}
> .item {
position: relative;
padding: 0 3px;

View File

@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:hasInteractionWithOtherFocusTrappedEls="true"
:transparentBg="true"
:manualShowing="manualShowing"
:src="src"
:anchorElement="anchorElement"
@click="modal?.close()"
@esc="modal?.close()"
@opening="opening"
@@ -44,11 +44,11 @@ import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
manualShowing?: boolean | null;
src?: HTMLElement;
anchorElement?: HTMLElement | null;
showPinned?: boolean;
pinnedEmojis?: string[],
asReactionPicker?: boolean;
targetNote?: Misskey.entities.Note;
targetNote?: Misskey.entities.Note | null;
choseAndClose?: boolean;
}>(), {
manualShowing: null,

View File

@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo>
<div v-if="isPlugin" class="_gaps_s">
<div v-if="extension.type === 'plugin'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #key>{{ i18n.ts.permission }}</template>
<template #value>
<ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList">
<li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
<li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] ?? permission }}</li>
</ul>
<template v-else>{{ i18n.ts.none }}</template>
</template>
@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCode :code="extension.raw"/>
</MkFolder>
</div>
<div v-else-if="isTheme" class="_gaps_s">
<div v-else-if="extension.type === 'theme'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
<template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template>
<template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template>
</MkKeyValue>
</div>
</MkFolder>
@@ -91,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</div>
<slot name="additionalInfo"/>
<slot name="additionalInfo"></slot>
<div class="_buttonsCenter">
<MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
@@ -101,6 +101,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts">
import * as Misskey from 'misskey-js';
export type Extension = {
type: 'plugin';
raw: string;
@@ -109,7 +111,7 @@ export type Extension = {
version: string;
author: string;
description?: string;
permissions?: string[];
permissions?: (typeof Misskey.permissions)[number][];
config?: Record<string, unknown>;
};
} | {
@@ -125,7 +127,6 @@ export type Extension = {
<script lang="ts" setup>
import { computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';

View File

@@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.describeFile }}</template>
<MkSpacer :marginMin="20" :marginMax="28">
<MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
<MkDriveFileThumbnail v-if="file" :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
<template #label>{{ i18n.ts.caption }}</template>
</MkTextarea>
</MkSpacer>
</div>
</MkModalWindow>
</template>
@@ -33,8 +33,8 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
file: Misskey.entities.DriveFile;
default: string;
file?: Misskey.entities.DriveFile | null;
default?: string | null;
}>();
const emit = defineEmits<{
@@ -44,7 +44,7 @@ const emit = defineEmits<{
const dialog = useTemplateRef('dialog');
const caption = ref(props.default);
const caption = ref(props.default ?? '');
async function ok() {
emit('done', caption.value);

View File

@@ -5,114 +5,122 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
<MkA
v-for="file in (items as Misskey.entities.DriveFile[])"
:key="file.id"
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`"
:to="`/admin/file/${file.id}`"
class="file _button"
<MkPagination v-slot="{ items }" :paginator="paginator">
<div
:class="{
[$style.grid]: viewMode === 'grid',
[$style.list]: viewMode === 'list',
'_gaps_s': viewMode === 'list',
}"
>
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
<div v-if="viewMode === 'list'" class="body">
<div>
<small style="opacity: 0.7;">{{ file.name }}</small>
<MkA
v-for="file in items"
:key="file.id"
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`"
:to="`/admin/file/${file.id}`"
:class="[$style.file, '_button']"
>
<div v-if="file.isSensitive" :class="$style.sensitiveLabel">{{ i18n.ts.sensitive }}</div>
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
<div v-if="viewMode === 'list'" :class="$style.body">
<div>
<small style="opacity: 0.7;">{{ file.name }}</small>
</div>
<div>
<MkAcct v-if="file.user" :user="file.user"/>
<div v-else>{{ i18n.ts.system }}</div>
</div>
<div>
<span style="margin-right: 1em;">{{ file.type }}</span>
<span>{{ bytes(file.size) }}</span>
</div>
<div>
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
</div>
</div>
<div>
<MkAcct v-if="file.user" :user="file.user"/>
<div v-else>{{ i18n.ts.system }}</div>
</div>
<div>
<span style="margin-right: 1em;">{{ file.type }}</span>
<span>{{ bytes(file.size) }}</span>
</div>
<div>
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
</div>
</div>
</MkA>
</MkA>
</div>
</MkPagination>
</div>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import type { Paginator } from '@/utility/paginator.js';
import MkPagination from '@/components/MkPagination.vue';
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import bytes from '@/filters/bytes.js';
import { i18n } from '@/i18n.js';
import { dateString } from '@/filters/date.js';
const props = defineProps<{
pagination: any;
defineProps<{
paginator: Paginator<'admin/drive/files'>;
viewMode: 'grid' | 'list';
}>();
</script>
<style lang="scss" scoped>
<style lang="scss" module>
@keyframes sensitive-blink {
0% { opacity: 1; }
50% { opacity: 0; }
}
.urempief {
&.list {
> .file {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
&:hover {
color: var(--MI_THEME-accent);
}
> .thumbnail {
width: 128px;
height: 128px;
}
> .body {
margin-left: 0.3em;
padding: 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
}
}
}
.list {
> .file {
display: flex;
width: 100%;
height: auto;
box-sizing: border-box;
text-align: left;
align-items: center;
}
&.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
grid-gap: 12px;
> .file:hover {
color: var(--MI_THEME-accent);
}
> .file {
position: relative;
aspect-ratio: 1;
> .file > .thumbnail {
width: 128px;
height: 128px;
}
> .thumbnail {
width: 100%;
height: 100%;
}
> .file > .body {
margin-left: 0.3em;
padding: 8px;
flex: 1;
> .sensitive-label {
position: absolute;
z-index: 10;
top: 8px;
left: 8px;
padding: 2px 4px;
background: #ff0000bf;
color: #fff;
border-radius: 4px;
font-size: 85%;
animation: sensitive-blink 1s infinite;
}
@media (max-width: 500px) {
font-size: 14px;
}
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
grid-gap: 12px;
> .file {
position: relative;
aspect-ratio: 1;
}
.thumbnail {
width: 100%;
height: 100%;
}
}
.sensitiveLabel {
position: absolute;
z-index: 10;
top: 8px;
left: 8px;
padding: 2px 4px;
background: #ff0000bf;
color: #fff;
border-radius: 4px;
font-size: 85%;
animation: sensitive-blink 1s infinite;
}
</style>

View File

@@ -31,9 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, ref, useTemplateRef, watch } from 'vue';
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { miLocalStorage } from '@/local-storage.js';
import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
import { getBgColor } from '@/utility/get-bg-color.js';
const miLocalStoragePrefix = 'ui:folder:' as const;
@@ -83,8 +84,19 @@ function afterLeave(el: Element) {
el.style.height = '';
}
function updateBgColor() {
if (rootEl.value) {
parentBg.value = getBgColor(rootEl.value.parentElement);
}
}
onMounted(() => {
parentBg.value = getBgColor(rootEl.value?.parentElement);
updateBgColor();
globalEvents.on('themeChanging', updateBgColor);
});
onBeforeUnmount(() => {
globalEvents.off('themeChanging', updateBgColor);
});
</script>

View File

@@ -19,13 +19,42 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.headerRight">
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
<i v-if="opened" class="ti ti-chevron-up icon"></i>
<i v-if="asPage" class="ti ti-chevron-right icon"></i>
<i v-else-if="opened" class="ti ti-chevron-up icon"></i>
<i v-else class="ti ti-chevron-down icon"></i>
</div>
</button>
</template>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
<div v-if="asPage">
<Teleport v-if="opened" defer :to="`#v-${pageId}-header`">
<slot name="label"></slot>
</Teleport>
<Teleport v-if="opened" defer :to="`#v-${pageId}-body`">
<MkStickyContainer>
<template #header>
<div v-if="$slots.header" :class="$style.inBodyHeader">
<slot name="header"></slot>
</div>
</template>
<div v-if="withSpacer" class="_spacer" :style="{ '--MI_SPACER-min': props.spacerMin + 'px', '--MI_SPACER-max': props.spacerMax + 'px' }">
<slot></slot>
</div>
<div v-else>
<slot></slot>
</div>
<template #footer>
<div v-if="$slots.footer" :class="$style.inBodyFooter">
<slot name="footer"></slot>
</div>
</template>
</MkStickyContainer>
</Teleport>
</div>
<div v-else-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
@@ -45,9 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
<div v-if="withSpacer" class="_spacer" :style="{ '--MI_SPACER-min': props.spacerMin + 'px', '--MI_SPACER-max': props.spacerMax + 'px' }">
<slot></slot>
</MkSpacer>
</div>
<div v-else>
<slot></slot>
</div>
@@ -67,9 +96,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { prefer } from '@/preferences.js';
import { getBgColor } from '@/utility/get-bg-color.js';
import { pageFolderTeleportCount, popup } from '@/os.js';
import MkFolderPage from '@/components/MkFolderPage.vue';
import { deviceKind } from '@/utility/device-kind.js';
const props = withDefaults(defineProps<{
defaultOpen?: boolean;
@@ -77,21 +109,32 @@ const props = withDefaults(defineProps<{
withSpacer?: boolean;
spacerMin?: number;
spacerMax?: number;
canPage?: boolean;
}>(), {
defaultOpen: false,
maxHeight: null,
withSpacer: true,
spacerMin: 14,
spacerMax: 22,
canPage: true,
});
const rootEl = useTemplateRef('rootEl');
const bgSame = ref(false);
const opened = ref(props.defaultOpen);
const openedAtLeastOnce = ref(props.defaultOpen);
const emit = defineEmits<{
(ev: 'opened'): void;
(ev: 'closed'): void;
}>();
const rootEl = useTemplateRef('rootEl');
const asPage = props.canPage && deviceKind === 'smartphone' && prefer.s['experimental.enableFolderPageView'];
const bgSame = ref(false);
const opened = ref(asPage ? false : props.defaultOpen);
const openedAtLeastOnce = ref(opened.value);
//#region interpolate-sizeに対応していないブラウザ向けTODO: 主要ブラウザが対応したら消す)
function enter(el: Element) {
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height;
el.style.height = '0';
el.offsetHeight; // reflow
@@ -99,12 +142,16 @@ function enter(el: Element) {
}
function afterEnter(el: Element) {
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
if (!(el instanceof HTMLElement)) return;
el.style.height = '';
}
function leave(el: Element) {
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
if (!(el instanceof HTMLElement)) return;
const elementHeight = el.getBoundingClientRect().height;
el.style.height = `${elementHeight}px`;
el.offsetHeight; // reflow
@@ -112,11 +159,29 @@ function leave(el: Element) {
}
function afterLeave(el: Element) {
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
if (!(el instanceof HTMLElement)) return;
el.style.height = '';
}
//#endregion
let pageId = pageFolderTeleportCount.value;
pageFolderTeleportCount.value += 1000;
async function toggle(ev: PointerEvent) {
if (asPage && !opened.value) {
pageId++;
const { dispose } = await popup(MkFolderPage, {
pageId,
}, {
closed: () => {
opened.value = false;
dispose();
},
});
}
function toggle() {
if (!opened.value) {
openedAtLeastOnce.value = true;
}
@@ -132,14 +197,34 @@ onMounted(() => {
const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
bgSame.value = parentBg === myBg;
});
watch(opened, (isOpened) => {
if (isOpened) {
emit('opened');
} else {
emit('closed');
}
}, { flush: 'post' });
</script>
<style lang="scss" module>
.transition_toggle_enterActive,
.transition_toggle_leaveActive {
overflow-y: clip;
transition: opacity 0.3s, height 0.3s, transform 0.3s !important;
overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない
transition: opacity 0.3s, height 0.3s;
}
@supports (interpolate-size: allow-keywords) {
.transition_toggle_enterFrom,
.transition_toggle_leaveTo {
height: 0;
}
.root {
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
}
}
.transition_toggle_enterFrom,
.transition_toggle_leaveTo {
opacity: 0;

Some files were not shown because too many files have changed in this diff Show More