mirror of
https://github.com/sim1222/misskey.git
synced 2025-08-04 07:26:29 +09:00
rename: client -> frontend
This commit is contained in:
33
packages/frontend/src/scripts/2fa.ts
Normal file
33
packages/frontend/src/scripts/2fa.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
|
||||
switch (encoding) {
|
||||
case 'ascii':
|
||||
return Uint8Array.from(string, c => c.charCodeAt(0));
|
||||
case 'base64':
|
||||
return Uint8Array.from(
|
||||
atob(
|
||||
string
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/'),
|
||||
),
|
||||
c => c.charCodeAt(0),
|
||||
);
|
||||
case 'hex':
|
||||
return new Uint8Array(
|
||||
string
|
||||
.match(/.{1,2}/g)
|
||||
.map(byte => parseInt(byte, 16)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hexify(buffer: ArrayBuffer) {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.reduce(
|
||||
(str, byte) => str + byte.toString(16).padStart(2, '0'),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
export function stringify(buffer: ArrayBuffer) {
|
||||
return String.fromCharCode(... new Uint8Array(buffer));
|
||||
}
|
43
packages/frontend/src/scripts/aiscript/api.ts
Normal file
43
packages/frontend/src/scripts/aiscript/api.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { utils, values } from '@syuilo/aiscript';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
|
||||
export function createAiScriptEnv(opts) {
|
||||
let apiRequests = 0;
|
||||
return {
|
||||
USER_ID: $i ? values.STR($i.id) : values.NULL,
|
||||
USER_NAME: $i ? values.STR($i.name) : values.NULL,
|
||||
USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
|
||||
'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
|
||||
await os.alert({
|
||||
type: type ? type.value : 'info',
|
||||
title: title.value,
|
||||
text: text.value,
|
||||
});
|
||||
}),
|
||||
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
|
||||
const confirm = await os.confirm({
|
||||
type: type ? type.value : 'question',
|
||||
title: title.value,
|
||||
text: text.value,
|
||||
});
|
||||
return confirm.canceled ? values.FALSE : values.TRUE;
|
||||
}),
|
||||
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
|
||||
if (token) utils.assertString(token);
|
||||
apiRequests++;
|
||||
if (apiRequests > 16) return values.NULL;
|
||||
const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null));
|
||||
return utils.jsToVal(res);
|
||||
}),
|
||||
'Mk:save': values.FN_NATIVE(([key, value]) => {
|
||||
utils.assertString(key);
|
||||
localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value)));
|
||||
return values.NULL;
|
||||
}),
|
||||
'Mk:load': values.FN_NATIVE(([key]) => {
|
||||
utils.assertString(key);
|
||||
return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value)));
|
||||
}),
|
||||
};
|
||||
}
|
149
packages/frontend/src/scripts/array.ts
Normal file
149
packages/frontend/src/scripts/array.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { EndoRelation, Predicate } from './relation';
|
||||
|
||||
/**
|
||||
* Count the number of elements that satisfy the predicate
|
||||
*/
|
||||
|
||||
export function countIf<T>(f: Predicate<T>, xs: T[]): number {
|
||||
return xs.filter(f).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of elements that is equal to the element
|
||||
*/
|
||||
export function count<T>(a: T, xs: T[]): number {
|
||||
return countIf(x => x === a, xs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate an array of arrays
|
||||
*/
|
||||
export function concat<T>(xss: T[][]): T[] {
|
||||
return ([] as T[]).concat(...xss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersperse the element between the elements of the array
|
||||
* @param sep The element to be interspersed
|
||||
*/
|
||||
export function intersperse<T>(sep: T, xs: T[]): T[] {
|
||||
return concat(xs.map(x => [sep, x])).slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of elements that is not equal to the element
|
||||
*/
|
||||
export function erase<T>(a: T, xs: T[]): T[] {
|
||||
return xs.filter(x => x !== a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the array of all elements in the first array not contained in the second array.
|
||||
* The order of result values are determined by the first array.
|
||||
*/
|
||||
export function difference<T>(xs: T[], ys: T[]): T[] {
|
||||
return xs.filter(x => !ys.includes(x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all but the first element from every group of equivalent elements
|
||||
*/
|
||||
export function unique<T>(xs: T[]): T[] {
|
||||
return [...new Set(xs)];
|
||||
}
|
||||
|
||||
export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
|
||||
const map = new Map<TKey, TValue>();
|
||||
|
||||
for (const value of values) {
|
||||
const key = keySelector(value);
|
||||
if (!map.has(key)) map.set(key, value);
|
||||
}
|
||||
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export function sum(xs: number[]): number {
|
||||
return xs.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
export function maximum(xs: number[]): number {
|
||||
return Math.max(...xs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an array based on the equivalence relation.
|
||||
* The concatenation of the result is equal to the argument.
|
||||
*/
|
||||
export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] {
|
||||
const groups = [] as T[][];
|
||||
for (const x of xs) {
|
||||
if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) {
|
||||
groups[groups.length - 1].push(x);
|
||||
} else {
|
||||
groups.push([x]);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an array based on the equivalence relation induced by the function.
|
||||
* The concatenation of the result is equal to the argument.
|
||||
*/
|
||||
export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] {
|
||||
return groupBy((a, b) => f(a) === f(b), xs);
|
||||
}
|
||||
|
||||
export function groupByX<T>(collections: T[], keySelector: (x: T) => string) {
|
||||
return collections.reduce((obj: Record<string, T[]>, item: T) => {
|
||||
const key = keySelector(item);
|
||||
if (typeof obj[key] === 'undefined') {
|
||||
obj[key] = [];
|
||||
}
|
||||
|
||||
obj[key].push(item);
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two arrays by lexicographical order
|
||||
*/
|
||||
export function lessThan(xs: number[], ys: number[]): boolean {
|
||||
for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
|
||||
if (xs[i] < ys[i]) return true;
|
||||
if (xs[i] > ys[i]) return false;
|
||||
}
|
||||
return xs.length < ys.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the longest prefix of elements that satisfy the predicate
|
||||
*/
|
||||
export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] {
|
||||
const ys: T[] = [];
|
||||
for (const x of xs) {
|
||||
if (f(x)) {
|
||||
ys.push(x);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ys;
|
||||
}
|
||||
|
||||
export function cumulativeSum(xs: number[]): number[] {
|
||||
const ys = Array.from(xs); // deep copy
|
||||
for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
|
||||
return ys;
|
||||
}
|
||||
|
||||
export function toArray<T>(x: T | T[] | undefined): T[] {
|
||||
return Array.isArray(x) ? x : x != null ? [x] : [];
|
||||
}
|
||||
|
||||
export function toSingle<T>(x: T | T[] | undefined): T | undefined {
|
||||
return Array.isArray(x) ? x[0] : x;
|
||||
}
|
276
packages/frontend/src/scripts/autocomplete.ts
Normal file
276
packages/frontend/src/scripts/autocomplete.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
|
||||
import getCaretCoordinates from 'textarea-caret';
|
||||
import { toASCII } from 'punycode/';
|
||||
import { popup } from '@/os';
|
||||
|
||||
export class Autocomplete {
|
||||
private suggestion: {
|
||||
x: Ref<number>;
|
||||
y: Ref<number>;
|
||||
q: Ref<string | null>;
|
||||
close: () => void;
|
||||
} | null;
|
||||
private textarea: HTMLInputElement | HTMLTextAreaElement;
|
||||
private currentType: string;
|
||||
private textRef: Ref<string>;
|
||||
private opening: boolean;
|
||||
|
||||
private get text(): string {
|
||||
// Use raw .value to get the latest value
|
||||
// (Because v-model does not update while composition)
|
||||
return this.textarea.value;
|
||||
}
|
||||
|
||||
private set text(text: string) {
|
||||
// Use ref value to notify other watchers
|
||||
// (Because .value setter never fires input/change events)
|
||||
this.textRef.value = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 対象のテキストエリアを与えてインスタンスを初期化します。
|
||||
*/
|
||||
constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
|
||||
//#region BIND
|
||||
this.onInput = this.onInput.bind(this);
|
||||
this.complete = this.complete.bind(this);
|
||||
this.close = this.close.bind(this);
|
||||
//#endregion
|
||||
|
||||
this.suggestion = null;
|
||||
this.textarea = textarea;
|
||||
this.textRef = textRef;
|
||||
this.opening = false;
|
||||
|
||||
this.attach();
|
||||
}
|
||||
|
||||
/**
|
||||
* このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
|
||||
*/
|
||||
public attach() {
|
||||
this.textarea.addEventListener('input', this.onInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
|
||||
*/
|
||||
public detach() {
|
||||
this.textarea.removeEventListener('input', this.onInput);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* テキスト入力時
|
||||
*/
|
||||
private onInput() {
|
||||
const caretPos = this.textarea.selectionStart;
|
||||
const text = this.text.substr(0, caretPos).split('\n').pop()!;
|
||||
|
||||
const mentionIndex = text.lastIndexOf('@');
|
||||
const hashtagIndex = text.lastIndexOf('#');
|
||||
const emojiIndex = text.lastIndexOf(':');
|
||||
const mfmTagIndex = text.lastIndexOf('$');
|
||||
|
||||
const max = Math.max(
|
||||
mentionIndex,
|
||||
hashtagIndex,
|
||||
emojiIndex,
|
||||
mfmTagIndex);
|
||||
|
||||
if (max === -1) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const isMention = mentionIndex !== -1;
|
||||
const isHashtag = hashtagIndex !== -1;
|
||||
const isMfmTag = mfmTagIndex !== -1;
|
||||
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
|
||||
|
||||
let opened = false;
|
||||
|
||||
if (isMention) {
|
||||
const username = text.substr(mentionIndex + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
|
||||
this.open('user', username);
|
||||
opened = true;
|
||||
} else if (username === '') {
|
||||
this.open('user', null);
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isHashtag && !opened) {
|
||||
const hashtag = text.substr(hashtagIndex + 1);
|
||||
if (!hashtag.includes(' ')) {
|
||||
this.open('hashtag', hashtag);
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmoji && !opened) {
|
||||
const emoji = text.substr(emojiIndex + 1);
|
||||
if (!emoji.includes(' ')) {
|
||||
this.open('emoji', emoji);
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMfmTag && !opened) {
|
||||
const mfmTag = text.substr(mfmTagIndex + 1);
|
||||
if (!mfmTag.includes(' ')) {
|
||||
this.open('mfmTag', mfmTag.replace('[', ''));
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* サジェストを提示します。
|
||||
*/
|
||||
private async open(type: string, q: string | null) {
|
||||
if (type !== this.currentType) {
|
||||
this.close();
|
||||
}
|
||||
if (this.opening) return;
|
||||
this.opening = true;
|
||||
this.currentType = type;
|
||||
|
||||
//#region サジェストを表示すべき位置を計算
|
||||
const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);
|
||||
|
||||
const rect = this.textarea.getBoundingClientRect();
|
||||
|
||||
const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
|
||||
const y = rect.top + caretPosition.top - this.textarea.scrollTop;
|
||||
//#endregion
|
||||
|
||||
if (this.suggestion) {
|
||||
this.suggestion.x.value = x;
|
||||
this.suggestion.y.value = y;
|
||||
this.suggestion.q.value = q;
|
||||
|
||||
this.opening = false;
|
||||
} else {
|
||||
const _x = ref(x);
|
||||
const _y = ref(y);
|
||||
const _q = ref(q);
|
||||
|
||||
const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), {
|
||||
textarea: this.textarea,
|
||||
close: this.close,
|
||||
type: type,
|
||||
q: _q,
|
||||
x: _x,
|
||||
y: _y,
|
||||
}, {
|
||||
done: (res) => {
|
||||
this.complete(res);
|
||||
},
|
||||
});
|
||||
|
||||
this.suggestion = {
|
||||
q: _q,
|
||||
x: _x,
|
||||
y: _y,
|
||||
close: () => dispose(),
|
||||
};
|
||||
|
||||
this.opening = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* サジェストを閉じます。
|
||||
*/
|
||||
private close() {
|
||||
if (this.suggestion == null) return;
|
||||
|
||||
this.suggestion.close();
|
||||
this.suggestion = null;
|
||||
|
||||
this.textarea.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* オートコンプリートする
|
||||
*/
|
||||
private complete({ type, value }) {
|
||||
this.close();
|
||||
|
||||
const caret = this.textarea.selectionStart;
|
||||
|
||||
if (type === 'user') {
|
||||
const source = this.text;
|
||||
|
||||
const before = source.substr(0, caret);
|
||||
const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
|
||||
const after = source.substr(caret);
|
||||
|
||||
const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
|
||||
|
||||
// 挿入
|
||||
this.text = `${trimmedBefore}@${acct} ${after}`;
|
||||
|
||||
// キャレットを戻す
|
||||
nextTick(() => {
|
||||
this.textarea.focus();
|
||||
const pos = trimmedBefore.length + (acct.length + 2);
|
||||
this.textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else if (type === 'hashtag') {
|
||||
const source = this.text;
|
||||
|
||||
const before = source.substr(0, caret);
|
||||
const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
|
||||
const after = source.substr(caret);
|
||||
|
||||
// 挿入
|
||||
this.text = `${trimmedBefore}#${value} ${after}`;
|
||||
|
||||
// キャレットを戻す
|
||||
nextTick(() => {
|
||||
this.textarea.focus();
|
||||
const pos = trimmedBefore.length + (value.length + 2);
|
||||
this.textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else if (type === 'emoji') {
|
||||
const source = this.text;
|
||||
|
||||
const before = source.substr(0, caret);
|
||||
const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
|
||||
const after = source.substr(caret);
|
||||
|
||||
// 挿入
|
||||
this.text = trimmedBefore + value + after;
|
||||
|
||||
// キャレットを戻す
|
||||
nextTick(() => {
|
||||
this.textarea.focus();
|
||||
const pos = trimmedBefore.length + value.length;
|
||||
this.textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else if (type === 'mfmTag') {
|
||||
const source = this.text;
|
||||
|
||||
const before = source.substr(0, caret);
|
||||
const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
|
||||
const after = source.substr(caret);
|
||||
|
||||
// 挿入
|
||||
this.text = `${trimmedBefore}$[${value} ]${after}`;
|
||||
|
||||
// キャレットを戻す
|
||||
nextTick(() => {
|
||||
this.textarea.focus();
|
||||
const pos = trimmedBefore.length + (value.length + 3);
|
||||
this.textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
21
packages/frontend/src/scripts/chart-vline.ts
Normal file
21
packages/frontend/src/scripts/chart-vline.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export const chartVLine = (vLineColor: string) => ({
|
||||
id: 'vLine',
|
||||
beforeDraw(chart, args, options) {
|
||||
if (chart.tooltip?._active?.length) {
|
||||
const activePoint = chart.tooltip._active[0];
|
||||
const ctx = chart.ctx;
|
||||
const x = activePoint.element.x;
|
||||
const topY = chart.scales.y.top;
|
||||
const bottomY = chart.scales.y.bottom;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bottomY);
|
||||
ctx.lineTo(x, topY);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = vLineColor;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
});
|
37
packages/frontend/src/scripts/check-word-mute.ts
Normal file
37
packages/frontend/src/scripts/check-word-mute.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean {
|
||||
// 自分自身
|
||||
if (me && (note.userId === me.id)) return false;
|
||||
|
||||
if (mutedWords.length > 0) {
|
||||
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
|
||||
|
||||
if (text === '') return false;
|
||||
|
||||
const matched = mutedWords.some(filter => {
|
||||
if (Array.isArray(filter)) {
|
||||
// Clean up
|
||||
const filteredFilter = filter.filter(keyword => keyword !== '');
|
||||
if (filteredFilter.length === 0) return false;
|
||||
|
||||
return filteredFilter.every(keyword => text.includes(keyword));
|
||||
} else {
|
||||
// represents RegExp
|
||||
const regexp = filter.match(/^\/(.+)\/(.*)$/);
|
||||
|
||||
// This should never happen due to input sanitisation.
|
||||
if (!regexp) return false;
|
||||
|
||||
try {
|
||||
return new RegExp(regexp[1], regexp[2]).test(text);
|
||||
} catch (err) {
|
||||
// This should never happen due to input sanitisation.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (matched) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
18
packages/frontend/src/scripts/clone.ts
Normal file
18
packages/frontend/src/scripts/clone.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// structredCloneが遅いため
|
||||
// SEE: http://var.blog.jp/archives/86038606.html
|
||||
|
||||
type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[];
|
||||
|
||||
export function deepClone<T extends Cloneable>(x: T): T {
|
||||
if (typeof x === 'object') {
|
||||
if (x === null) return x;
|
||||
if (Array.isArray(x)) return x.map(deepClone) as T;
|
||||
const obj = {} as Record<string, Cloneable>;
|
||||
for (const [k, v] of Object.entries(x)) {
|
||||
obj[k] = deepClone(v);
|
||||
}
|
||||
return obj as T;
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}
|
68
packages/frontend/src/scripts/collect-page-vars.ts
Normal file
68
packages/frontend/src/scripts/collect-page-vars.ts
Normal file
@ -0,0 +1,68 @@
|
||||
interface StringPageVar {
|
||||
name: string,
|
||||
type: 'string',
|
||||
value: string
|
||||
}
|
||||
|
||||
interface NumberPageVar {
|
||||
name: string,
|
||||
type: 'number',
|
||||
value: number
|
||||
}
|
||||
|
||||
interface BooleanPageVar {
|
||||
name: string,
|
||||
type: 'boolean',
|
||||
value: boolean
|
||||
}
|
||||
|
||||
type PageVar = StringPageVar | NumberPageVar | BooleanPageVar;
|
||||
|
||||
export function collectPageVars(content): PageVar[] {
|
||||
const pageVars: PageVar[] = [];
|
||||
const collect = (xs: any[]): void => {
|
||||
for (const x of xs) {
|
||||
if (x.type === 'textInput') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'string',
|
||||
value: x.default || '',
|
||||
});
|
||||
} else if (x.type === 'textareaInput') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'string',
|
||||
value: x.default || '',
|
||||
});
|
||||
} else if (x.type === 'numberInput') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'number',
|
||||
value: x.default || 0,
|
||||
});
|
||||
} else if (x.type === 'switch') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'boolean',
|
||||
value: x.default || false,
|
||||
});
|
||||
} else if (x.type === 'counter') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'number',
|
||||
value: 0,
|
||||
});
|
||||
} else if (x.type === 'radioButton') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'string',
|
||||
value: x.default || '',
|
||||
});
|
||||
} else if (x.children) {
|
||||
collect(x.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(content);
|
||||
return pageVars;
|
||||
}
|
9
packages/frontend/src/scripts/contains.ts
Normal file
9
packages/frontend/src/scripts/contains.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export default (parent, child, checkSame = true) => {
|
||||
if (checkSame && parent === child) return true;
|
||||
let node = child.parentNode;
|
||||
while (node) {
|
||||
if (node === parent) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
};
|
33
packages/frontend/src/scripts/copy-to-clipboard.ts
Normal file
33
packages/frontend/src/scripts/copy-to-clipboard.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Clipboardに値をコピー(TODO: 文字列以外も対応)
|
||||
*/
|
||||
export default val => {
|
||||
// 空div 生成
|
||||
const tmp = document.createElement('div');
|
||||
// 選択用のタグ生成
|
||||
const pre = document.createElement('pre');
|
||||
|
||||
// 親要素のCSSで user-select: none だとコピーできないので書き換える
|
||||
pre.style.webkitUserSelect = 'auto';
|
||||
pre.style.userSelect = 'auto';
|
||||
|
||||
tmp.appendChild(pre).textContent = val;
|
||||
|
||||
// 要素を画面外へ
|
||||
const s = tmp.style;
|
||||
s.position = 'fixed';
|
||||
s.right = '200%';
|
||||
|
||||
// body に追加
|
||||
document.body.appendChild(tmp);
|
||||
// 要素を選択
|
||||
document.getSelection().selectAllChildren(tmp);
|
||||
|
||||
// クリップボードにコピー
|
||||
const result = document.execCommand('copy');
|
||||
|
||||
// 要素削除
|
||||
document.body.removeChild(tmp);
|
||||
|
||||
return result;
|
||||
};
|
10
packages/frontend/src/scripts/device-kind.ts
Normal file
10
packages/frontend/src/scripts/device-kind.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
|
||||
const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
|
||||
|
||||
export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind
|
||||
: isSmartphone ? 'smartphone'
|
||||
: isTablet ? 'tablet'
|
||||
: 'desktop';
|
20
packages/frontend/src/scripts/emoji-base.ts
Normal file
20
packages/frontend/src/scripts/emoji-base.ts
Normal file
@ -0,0 +1,20 @@
|
||||
const twemojiSvgBase = '/twemoji';
|
||||
const fluentEmojiPngBase = '/fluent-emoji';
|
||||
|
||||
export function char2twemojiFilePath(char: string): string {
|
||||
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
const fileName = codes.join('-');
|
||||
return `${twemojiSvgBase}/${fileName}.svg`;
|
||||
}
|
||||
|
||||
export function char2fluentEmojiFilePath(char: string): string {
|
||||
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
|
||||
// Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25
|
||||
if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char);
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
const fileName = codes.map(x => x!.padStart(4, '0')).join('-');
|
||||
return `${fluentEmojiPngBase}/${fileName}.png`;
|
||||
}
|
17
packages/frontend/src/scripts/emojilist.ts
Normal file
17
packages/frontend/src/scripts/emojilist.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const;
|
||||
|
||||
export type UnicodeEmojiDef = {
|
||||
name: string;
|
||||
keywords: string[];
|
||||
char: string;
|
||||
category: typeof unicodeEmojiCategories[number];
|
||||
}
|
||||
|
||||
// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
|
||||
import _emojilist from '../emojilist.json';
|
||||
|
||||
export const emojilist = _emojilist as UnicodeEmojiDef[];
|
||||
|
||||
export function getEmojiName(char: string): string | undefined {
|
||||
return emojilist.find(emo => emo.char === char)?.name;
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
export function extractAvgColorFromBlurhash(hash: string) {
|
||||
return typeof hash === 'string'
|
||||
? '#' + [...hash.slice(2, 6)]
|
||||
.map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x))
|
||||
.reduce((a, c) => a * 83 + c, 0)
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
: undefined;
|
||||
}
|
11
packages/frontend/src/scripts/extract-mentions.ts
Normal file
11
packages/frontend/src/scripts/extract-mentions.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// test is located in test/extract-mentions
|
||||
|
||||
import * as mfm from 'mfm-js';
|
||||
|
||||
export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
|
||||
// TODO: 重複を削除
|
||||
const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
|
||||
const mentions = mentionNodes.map(x => x.props);
|
||||
|
||||
return mentions;
|
||||
}
|
19
packages/frontend/src/scripts/extract-url-from-mfm.ts
Normal file
19
packages/frontend/src/scripts/extract-url-from-mfm.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import * as mfm from 'mfm-js';
|
||||
import { unique } from '@/scripts/array';
|
||||
|
||||
// unique without hash
|
||||
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
||||
const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
|
||||
|
||||
export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
|
||||
const urlNodes = mfm.extract(nodes, (node) => {
|
||||
return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
|
||||
});
|
||||
const urls: string[] = unique(urlNodes.map(x => x.props.url));
|
||||
|
||||
return urls.reduce((array, url) => {
|
||||
const urlWithoutHash = removeHash(url);
|
||||
if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
|
||||
return array;
|
||||
}, [] as string[]);
|
||||
}
|
27
packages/frontend/src/scripts/focus.ts
Normal file
27
packages/frontend/src/scripts/focus.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export function focusPrev(el: Element | null, self = false, scroll = true) {
|
||||
if (el == null) return;
|
||||
if (!self) el = el.previousElementSibling;
|
||||
if (el) {
|
||||
if (el.hasAttribute('tabindex')) {
|
||||
(el as HTMLElement).focus({
|
||||
preventScroll: !scroll,
|
||||
});
|
||||
} else {
|
||||
focusPrev(el.previousElementSibling, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function focusNext(el: Element | null, self = false, scroll = true) {
|
||||
if (el == null) return;
|
||||
if (!self) el = el.nextElementSibling;
|
||||
if (el) {
|
||||
if (el.hasAttribute('tabindex')) {
|
||||
(el as HTMLElement).focus({
|
||||
preventScroll: !scroll,
|
||||
});
|
||||
} else {
|
||||
focusPrev(el.nextElementSibling, true);
|
||||
}
|
||||
}
|
||||
}
|
59
packages/frontend/src/scripts/form.ts
Normal file
59
packages/frontend/src/scripts/form.ts
Normal file
@ -0,0 +1,59 @@
|
||||
export type FormItem = {
|
||||
label?: string;
|
||||
type: 'string';
|
||||
default: string | null;
|
||||
hidden?: boolean;
|
||||
multiline?: boolean;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'number';
|
||||
default: number | null;
|
||||
hidden?: boolean;
|
||||
step?: number;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'boolean';
|
||||
default: boolean | null;
|
||||
hidden?: boolean;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'enum';
|
||||
default: string | null;
|
||||
hidden?: boolean;
|
||||
enum: string[];
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'radio';
|
||||
default: unknown | null;
|
||||
hidden?: boolean;
|
||||
options: {
|
||||
label: string;
|
||||
value: unknown;
|
||||
}[];
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'object';
|
||||
default: Record<string, unknown> | null;
|
||||
hidden: true;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'array';
|
||||
default: unknown[] | null;
|
||||
hidden: true;
|
||||
};
|
||||
|
||||
export type Form = Record<string, FormItem>;
|
||||
|
||||
type GetItemType<Item extends FormItem> =
|
||||
Item['type'] extends 'string' ? string :
|
||||
Item['type'] extends 'number' ? number :
|
||||
Item['type'] extends 'boolean' ? boolean :
|
||||
Item['type'] extends 'radio' ? unknown :
|
||||
Item['type'] extends 'enum' ? string :
|
||||
Item['type'] extends 'array' ? unknown[] :
|
||||
Item['type'] extends 'object' ? Record<string, unknown>
|
||||
: never;
|
||||
|
||||
export type GetFormResultType<F extends Form> = {
|
||||
[P in keyof F]: GetItemType<F[P]>;
|
||||
};
|
50
packages/frontend/src/scripts/format-time-string.ts
Normal file
50
packages/frontend/src/scripts/format-time-string.ts
Normal file
@ -0,0 +1,50 @@
|
||||
const defaultLocaleStringFormats: {[index: string]: string} = {
|
||||
'weekday': 'narrow',
|
||||
'era': 'narrow',
|
||||
'year': 'numeric',
|
||||
'month': 'numeric',
|
||||
'day': 'numeric',
|
||||
'hour': 'numeric',
|
||||
'minute': 'numeric',
|
||||
'second': 'numeric',
|
||||
'timeZoneName': 'short',
|
||||
};
|
||||
|
||||
function formatLocaleString(date: Date, format: string): string {
|
||||
return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
|
||||
if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
|
||||
return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] });
|
||||
} else {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTimeString(date: Date, format: string): string {
|
||||
return format
|
||||
.replace(/yyyy/g, date.getFullYear().toString())
|
||||
.replace(/yy/g, date.getFullYear().toString().slice(-2))
|
||||
.replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' }))
|
||||
.replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' }))
|
||||
.replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
|
||||
.replace(/M/g, (date.getMonth() + 1).toString())
|
||||
.replace(/dd/g, (`0${date.getDate()}`).slice(-2))
|
||||
.replace(/d/g, date.getDate().toString())
|
||||
.replace(/HH/g, (`0${date.getHours()}`).slice(-2))
|
||||
.replace(/H/g, date.getHours().toString())
|
||||
.replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
|
||||
.replace(/h/g, ((date.getHours() % 12) || 12).toString())
|
||||
.replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
|
||||
.replace(/m/g, date.getMinutes().toString())
|
||||
.replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
|
||||
.replace(/s/g, date.getSeconds().toString())
|
||||
.replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
|
||||
}
|
||||
|
||||
export function formatTimeString(date: Date, format: string): string {
|
||||
return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
|
||||
if (localeformat) return formatLocaleString(date, localeformat);
|
||||
if (datetimeformat) return formatDateTimeString(date, datetimeformat);
|
||||
return match;
|
||||
});
|
||||
}
|
30
packages/frontend/src/scripts/gen-search-query.ts
Normal file
30
packages/frontend/src/scripts/gen-search-query.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import { host as localHost } from '@/config';
|
||||
|
||||
export async function genSearchQuery(v: any, q: string) {
|
||||
let host: string;
|
||||
let userId: string;
|
||||
if (q.split(' ').some(x => x.startsWith('@'))) {
|
||||
for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) {
|
||||
if (at.includes('.')) {
|
||||
if (at === localHost || at === '.') {
|
||||
host = null;
|
||||
} else {
|
||||
host = at;
|
||||
}
|
||||
} else {
|
||||
const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null);
|
||||
if (user) {
|
||||
userId = user.id;
|
||||
} else {
|
||||
// todo: show error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
|
||||
host: host,
|
||||
userId: userId,
|
||||
};
|
||||
}
|
7
packages/frontend/src/scripts/get-account-from-id.ts
Normal file
7
packages/frontend/src/scripts/get-account-from-id.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { get } from '@/scripts/idb-proxy';
|
||||
|
||||
export async function getAccountFromId(id: string) {
|
||||
const accounts = await get('accounts') as { token: string; id: string; }[];
|
||||
if (!accounts) console.log('Accounts are not recorded');
|
||||
return accounts.find(account => account.id === id);
|
||||
}
|
341
packages/frontend/src/scripts/get-note-menu.ts
Normal file
341
packages/frontend/src/scripts/get-note-menu.ts
Normal file
@ -0,0 +1,341 @@
|
||||
import { defineAsyncComponent, Ref, inject } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { pleaseLogin } from './please-login';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
import * as os from '@/os';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import { url } from '@/config';
|
||||
import { noteActions } from '@/store';
|
||||
import { notePage } from '@/filters/note';
|
||||
|
||||
export function getNoteMenu(props: {
|
||||
note: misskey.entities.Note;
|
||||
menuButton: Ref<HTMLElement>;
|
||||
translation: Ref<any>;
|
||||
translating: Ref<boolean>;
|
||||
isDeleted: Ref<boolean>;
|
||||
currentClipPage?: Ref<misskey.entities.Clip>;
|
||||
}) {
|
||||
const isRenote = (
|
||||
props.note.renote != null &&
|
||||
props.note.text == null &&
|
||||
props.note.fileIds.length === 0 &&
|
||||
props.note.poll == null
|
||||
);
|
||||
|
||||
const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
|
||||
|
||||
function del(): void {
|
||||
os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.noteDeleteConfirm,
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
|
||||
os.api('notes/delete', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delEdit(): void {
|
||||
os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.deleteAndEditConfirm,
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
|
||||
os.api('notes/delete', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
|
||||
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFavorite(favorite: boolean): void {
|
||||
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleThreadMute(mute: boolean): void {
|
||||
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
}
|
||||
|
||||
function copyContent(): void {
|
||||
copyToClipboard(appearNote.text);
|
||||
os.success();
|
||||
}
|
||||
|
||||
function copyLink(): void {
|
||||
copyToClipboard(`${url}/notes/${appearNote.id}`);
|
||||
os.success();
|
||||
}
|
||||
|
||||
function togglePin(pin: boolean): void {
|
||||
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
|
||||
noteId: appearNote.id,
|
||||
}, undefined, null, res => {
|
||||
if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.pinLimitExceeded,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
const clips = await os.api('clips/list');
|
||||
os.popupMenu([{
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.createNew,
|
||||
action: async () => {
|
||||
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
|
||||
name: {
|
||||
type: 'string',
|
||||
label: i18n.ts.name,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
multiline: true,
|
||||
label: i18n.ts.description,
|
||||
},
|
||||
isPublic: {
|
||||
type: 'boolean',
|
||||
label: i18n.ts.public,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const clip = await os.apiWithDialog('clips/create', result);
|
||||
|
||||
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
|
||||
},
|
||||
}, null, ...clips.map(clip => ({
|
||||
text: clip.name,
|
||||
action: () => {
|
||||
os.promiseDialog(
|
||||
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
|
||||
null,
|
||||
async (err) => {
|
||||
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
|
||||
});
|
||||
if (!confirm.canceled) {
|
||||
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
|
||||
if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
|
||||
}
|
||||
} else {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.message + '\n' + err.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
}))], props.menuButton.value, {
|
||||
}).then(focus);
|
||||
}
|
||||
|
||||
async function unclip(): Promise<void> {
|
||||
os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
|
||||
props.isDeleted.value = true;
|
||||
}
|
||||
|
||||
async function promote(): Promise<void> {
|
||||
const { canceled, result: days } = await os.inputNumber({
|
||||
title: i18n.ts.numberOfDays,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('admin/promo/create', {
|
||||
noteId: appearNote.id,
|
||||
expiresAt: Date.now() + (86400000 * days),
|
||||
});
|
||||
}
|
||||
|
||||
function share(): void {
|
||||
navigator.share({
|
||||
title: i18n.t('noteOf', { user: appearNote.user.name }),
|
||||
text: appearNote.text,
|
||||
url: `${url}/notes/${appearNote.id}`,
|
||||
});
|
||||
}
|
||||
function notedetails(): void {
|
||||
os.pageWindow(`/notes/${appearNote.id}`);
|
||||
}
|
||||
async function translate(): Promise<void> {
|
||||
if (props.translation.value != null) return;
|
||||
props.translating.value = true;
|
||||
const res = await os.api('notes/translate', {
|
||||
noteId: appearNote.id,
|
||||
targetLang: localStorage.getItem('lang') || navigator.language,
|
||||
});
|
||||
props.translating.value = false;
|
||||
props.translation.value = res;
|
||||
}
|
||||
|
||||
let menu;
|
||||
if ($i) {
|
||||
const statePromise = os.api('notes/state', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
|
||||
menu = [
|
||||
...(
|
||||
props.currentClipPage?.value.userId === $i.id ? [{
|
||||
icon: 'ti ti-backspace',
|
||||
text: i18n.ts.unclip,
|
||||
danger: true,
|
||||
action: unclip,
|
||||
}, null] : []
|
||||
), {
|
||||
icon: 'ti ti-external-link',
|
||||
text: i18n.ts.details,
|
||||
action: notedetails,
|
||||
}, {
|
||||
icon: 'ti ti-copy',
|
||||
text: i18n.ts.copyContent,
|
||||
action: copyContent,
|
||||
}, {
|
||||
icon: 'ti ti-link',
|
||||
text: i18n.ts.copyLink,
|
||||
action: copyLink,
|
||||
}, (appearNote.url || appearNote.uri) ? {
|
||||
icon: 'ti ti-external-link',
|
||||
text: i18n.ts.showOnRemote,
|
||||
action: () => {
|
||||
window.open(appearNote.url || appearNote.uri, '_blank');
|
||||
},
|
||||
} : undefined,
|
||||
{
|
||||
icon: 'ti ti-share',
|
||||
text: i18n.ts.share,
|
||||
action: share,
|
||||
},
|
||||
instance.translatorAvailable ? {
|
||||
icon: 'ti ti-language-hiragana',
|
||||
text: i18n.ts.translate,
|
||||
action: translate,
|
||||
} : undefined,
|
||||
null,
|
||||
statePromise.then(state => state.isFavorited ? {
|
||||
icon: 'ti ti-star-off',
|
||||
text: i18n.ts.unfavorite,
|
||||
action: () => toggleFavorite(false),
|
||||
} : {
|
||||
icon: 'ti ti-star',
|
||||
text: i18n.ts.favorite,
|
||||
action: () => toggleFavorite(true),
|
||||
}),
|
||||
{
|
||||
icon: 'ti ti-paperclip',
|
||||
text: i18n.ts.clip,
|
||||
action: () => clip(),
|
||||
},
|
||||
statePromise.then(state => state.isMutedThread ? {
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.unmuteThread,
|
||||
action: () => toggleThreadMute(false),
|
||||
} : {
|
||||
icon: 'ti ti-message-off',
|
||||
text: i18n.ts.muteThread,
|
||||
action: () => toggleThreadMute(true),
|
||||
}),
|
||||
appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
|
||||
icon: 'ti ti-pinned-off',
|
||||
text: i18n.ts.unpin,
|
||||
action: () => togglePin(false),
|
||||
} : {
|
||||
icon: 'ti ti-pin',
|
||||
text: i18n.ts.pin,
|
||||
action: () => togglePin(true),
|
||||
} : undefined,
|
||||
/*
|
||||
...($i.isModerator || $i.isAdmin ? [
|
||||
null,
|
||||
{
|
||||
icon: 'fas fa-bullhorn',
|
||||
text: i18n.ts.promote,
|
||||
action: promote
|
||||
}]
|
||||
: []
|
||||
),*/
|
||||
...(appearNote.userId !== $i.id ? [
|
||||
null,
|
||||
{
|
||||
icon: 'ti ti-exclamation-circle',
|
||||
text: i18n.ts.reportAbuse,
|
||||
action: () => {
|
||||
const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`;
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
|
||||
user: appearNote.user,
|
||||
initialComment: `Note: ${u}\n-----\n`,
|
||||
}, {}, 'closed');
|
||||
},
|
||||
}]
|
||||
: []
|
||||
),
|
||||
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
|
||||
null,
|
||||
appearNote.userId === $i.id ? {
|
||||
icon: 'ti ti-edit',
|
||||
text: i18n.ts.deleteAndEdit,
|
||||
action: delEdit,
|
||||
} : undefined,
|
||||
{
|
||||
icon: 'ti ti-trash',
|
||||
text: i18n.ts.delete,
|
||||
danger: true,
|
||||
action: del,
|
||||
}]
|
||||
: []
|
||||
)]
|
||||
.filter(x => x !== undefined);
|
||||
} else {
|
||||
menu = [{
|
||||
icon: 'ti ti-external-link',
|
||||
text: i18n.ts.detailed,
|
||||
action: openDetail,
|
||||
}, {
|
||||
icon: 'ti ti-copy',
|
||||
text: i18n.ts.copyContent,
|
||||
action: copyContent,
|
||||
}, {
|
||||
icon: 'ti ti-link',
|
||||
text: i18n.ts.copyLink,
|
||||
action: copyLink,
|
||||
}, (appearNote.url || appearNote.uri) ? {
|
||||
icon: 'ti ti-external-link',
|
||||
text: i18n.ts.showOnRemote,
|
||||
action: () => {
|
||||
window.open(appearNote.url || appearNote.uri, '_blank');
|
||||
},
|
||||
} : undefined]
|
||||
.filter(x => x !== undefined);
|
||||
}
|
||||
|
||||
if (noteActions.length > 0) {
|
||||
menu = menu.concat([null, ...noteActions.map(action => ({
|
||||
icon: 'ti ti-plug',
|
||||
text: action.title,
|
||||
action: () => {
|
||||
action.handler(appearNote);
|
||||
},
|
||||
}))]);
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
55
packages/frontend/src/scripts/get-note-summary.ts
Normal file
55
packages/frontend/src/scripts/get-note-summary.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import * as misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
/**
|
||||
* 投稿を表す文字列を取得します。
|
||||
* @param {*} note (packされた)投稿
|
||||
*/
|
||||
export const getNoteSummary = (note: misskey.entities.Note): string => {
|
||||
if (note.deletedAt) {
|
||||
return `(${i18n.ts.deletedNote})`;
|
||||
}
|
||||
|
||||
if (note.isHidden) {
|
||||
return `(${i18n.ts.invisibleNote})`;
|
||||
}
|
||||
|
||||
let summary = '';
|
||||
|
||||
// 本文
|
||||
if (note.cw != null) {
|
||||
summary += note.cw;
|
||||
} else {
|
||||
summary += note.text ? note.text : '';
|
||||
}
|
||||
|
||||
// ファイルが添付されているとき
|
||||
if ((note.files || []).length !== 0) {
|
||||
summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
|
||||
}
|
||||
|
||||
// 投票が添付されているとき
|
||||
if (note.poll) {
|
||||
summary += ` (${i18n.ts.poll})`;
|
||||
}
|
||||
|
||||
// 返信のとき
|
||||
if (note.replyId) {
|
||||
if (note.reply) {
|
||||
summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
|
||||
} else {
|
||||
summary += '\n\nRE: ...';
|
||||
}
|
||||
}
|
||||
|
||||
// Renoteのとき
|
||||
if (note.renoteId) {
|
||||
if (note.renote) {
|
||||
summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
|
||||
} else {
|
||||
summary += '\n\nRN: ...';
|
||||
}
|
||||
}
|
||||
|
||||
return summary.trim();
|
||||
};
|
19
packages/frontend/src/scripts/get-static-image-url.ts
Normal file
19
packages/frontend/src/scripts/get-static-image-url.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { url as instanceUrl } from '@/config';
|
||||
import * as url from '@/scripts/url';
|
||||
|
||||
export function getStaticImageUrl(baseUrl: string): string {
|
||||
const u = new URL(baseUrl);
|
||||
if (u.href.startsWith(`${instanceUrl}/proxy/`)) {
|
||||
// もう既にproxyっぽそうだったらsearchParams付けるだけ
|
||||
u.searchParams.set('static', '1');
|
||||
return u.href;
|
||||
}
|
||||
|
||||
// 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する
|
||||
const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`;
|
||||
|
||||
return `${instanceUrl}/proxy/${dummy}?${url.query({
|
||||
url: u.href,
|
||||
static: '1',
|
||||
})}`;
|
||||
}
|
253
packages/frontend/src/scripts/get-user-menu.ts
Normal file
253
packages/frontend/src/scripts/get-user-menu.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import { host } from '@/config';
|
||||
import * as os from '@/os';
|
||||
import { userActions } from '@/store';
|
||||
import { $i, iAmModerator } from '@/account';
|
||||
import { mainRouter } from '@/router';
|
||||
import { Router } from '@/nirax';
|
||||
|
||||
export function getUserMenu(user, router: Router = mainRouter) {
|
||||
const meId = $i ? $i.id : null;
|
||||
|
||||
async function pushList() {
|
||||
const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
|
||||
const lists = await os.api('users/lists/list');
|
||||
if (lists.length === 0) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.youHaveNoLists,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { canceled, result: listId } = await os.select({
|
||||
title: t,
|
||||
items: lists.map(list => ({
|
||||
value: list.id, text: list.name,
|
||||
})),
|
||||
});
|
||||
if (canceled) return;
|
||||
os.apiWithDialog('users/lists/push', {
|
||||
listId: listId,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function inviteGroup() {
|
||||
const groups = await os.api('users/groups/owned');
|
||||
if (groups.length === 0) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.youHaveNoGroups,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { canceled, result: groupId } = await os.select({
|
||||
title: i18n.ts.group,
|
||||
items: groups.map(group => ({
|
||||
value: group.id, text: group.name,
|
||||
})),
|
||||
});
|
||||
if (canceled) return;
|
||||
os.apiWithDialog('users/groups/invite', {
|
||||
groupId: groupId,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleMute() {
|
||||
if (user.isMuted) {
|
||||
os.apiWithDialog('mute/delete', {
|
||||
userId: user.id,
|
||||
}).then(() => {
|
||||
user.isMuted = false;
|
||||
});
|
||||
} else {
|
||||
const { canceled, result: period } = await os.select({
|
||||
title: i18n.ts.mutePeriod,
|
||||
items: [{
|
||||
value: 'indefinitely', text: i18n.ts.indefinitely,
|
||||
}, {
|
||||
value: 'tenMinutes', text: i18n.ts.tenMinutes,
|
||||
}, {
|
||||
value: 'oneHour', text: i18n.ts.oneHour,
|
||||
}, {
|
||||
value: 'oneDay', text: i18n.ts.oneDay,
|
||||
}, {
|
||||
value: 'oneWeek', text: i18n.ts.oneWeek,
|
||||
}],
|
||||
default: 'indefinitely',
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const expiresAt = period === 'indefinitely' ? null
|
||||
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
|
||||
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
|
||||
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
|
||||
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
|
||||
: null;
|
||||
|
||||
os.apiWithDialog('mute/create', {
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
}).then(() => {
|
||||
user.isMuted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBlock() {
|
||||
if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
|
||||
|
||||
os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
|
||||
userId: user.id,
|
||||
}).then(() => {
|
||||
user.isBlocking = !user.isBlocking;
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSilence() {
|
||||
if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
|
||||
|
||||
os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
|
||||
userId: user.id,
|
||||
}).then(() => {
|
||||
user.isSilenced = !user.isSilenced;
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSuspend() {
|
||||
if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
|
||||
|
||||
os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
|
||||
userId: user.id,
|
||||
}).then(() => {
|
||||
user.isSuspended = !user.isSuspended;
|
||||
});
|
||||
}
|
||||
|
||||
function reportAbuse() {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
|
||||
user: user,
|
||||
}, {}, 'closed');
|
||||
}
|
||||
|
||||
async function getConfirmed(text: string): Promise<boolean> {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
title: 'confirm',
|
||||
text,
|
||||
});
|
||||
|
||||
return !confirm.canceled;
|
||||
}
|
||||
|
||||
async function invalidateFollow() {
|
||||
os.apiWithDialog('following/invalidate', {
|
||||
userId: user.id,
|
||||
}).then(() => {
|
||||
user.isFollowed = !user.isFollowed;
|
||||
});
|
||||
}
|
||||
|
||||
let menu = [{
|
||||
icon: 'ti ti-at',
|
||||
text: i18n.ts.copyUsername,
|
||||
action: () => {
|
||||
copyToClipboard(`@${user.username}@${user.host || host}`);
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-rss',
|
||||
text: i18n.ts.copyRSS,
|
||||
action: () => {
|
||||
copyToClipboard(`${user.host || host}/@${user.username}.atom`);
|
||||
}
|
||||
}, {
|
||||
icon: 'ti ti-info-circle',
|
||||
text: i18n.ts.info,
|
||||
action: () => {
|
||||
router.push(`/user-info/${user.id}`);
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-mail',
|
||||
text: i18n.ts.sendMessage,
|
||||
action: () => {
|
||||
os.post({ specified: user });
|
||||
},
|
||||
}, meId !== user.id ? {
|
||||
type: 'link',
|
||||
icon: 'ti ti-messages',
|
||||
text: i18n.ts.startMessaging,
|
||||
to: '/my/messaging/' + Acct.toString(user),
|
||||
} : undefined, null, {
|
||||
icon: 'ti ti-list',
|
||||
text: i18n.ts.addToList,
|
||||
action: pushList,
|
||||
}, meId !== user.id ? {
|
||||
icon: 'ti ti-users',
|
||||
text: i18n.ts.inviteToGroup,
|
||||
action: inviteGroup,
|
||||
} : undefined] as any;
|
||||
|
||||
if ($i && meId !== user.id) {
|
||||
menu = menu.concat([null, {
|
||||
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
|
||||
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
|
||||
action: toggleMute,
|
||||
}, {
|
||||
icon: 'ti ti-ban',
|
||||
text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
|
||||
action: toggleBlock,
|
||||
}]);
|
||||
|
||||
if (user.isFollowed) {
|
||||
menu = menu.concat([{
|
||||
icon: 'ti ti-link-off',
|
||||
text: i18n.ts.breakFollow,
|
||||
action: invalidateFollow,
|
||||
}]);
|
||||
}
|
||||
|
||||
menu = menu.concat([null, {
|
||||
icon: 'ti ti-exclamation-circle',
|
||||
text: i18n.ts.reportAbuse,
|
||||
action: reportAbuse,
|
||||
}]);
|
||||
|
||||
if (iAmModerator) {
|
||||
menu = menu.concat([null, {
|
||||
icon: 'ti ti-microphone-2-off',
|
||||
text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence,
|
||||
action: toggleSilence,
|
||||
}, {
|
||||
icon: 'ti ti-snowflake',
|
||||
text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend,
|
||||
action: toggleSuspend,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($i && meId === user.id) {
|
||||
menu = menu.concat([null, {
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.editProfile,
|
||||
action: () => {
|
||||
router.push('/settings/profile');
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
if (userActions.length > 0) {
|
||||
menu = menu.concat([null, ...userActions.map(action => ({
|
||||
icon: 'ti ti-plug',
|
||||
text: action.title,
|
||||
action: () => {
|
||||
action.handler(user);
|
||||
},
|
||||
}))]);
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
3
packages/frontend/src/scripts/get-user-name.ts
Normal file
3
packages/frontend/src/scripts/get-user-name.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default function(user: { name?: string | null, username: string }): string {
|
||||
return user.name || user.username;
|
||||
}
|
90
packages/frontend/src/scripts/hotkey.ts
Normal file
90
packages/frontend/src/scripts/hotkey.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import keyCode from './keycode';
|
||||
|
||||
type Callback = (ev: KeyboardEvent) => void;
|
||||
|
||||
type Keymap = Record<string, Callback>;
|
||||
|
||||
type Pattern = {
|
||||
which: string[];
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
};
|
||||
|
||||
type Action = {
|
||||
patterns: Pattern[];
|
||||
callback: Callback;
|
||||
allowRepeat: boolean;
|
||||
};
|
||||
|
||||
const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
|
||||
const result = {
|
||||
patterns: [],
|
||||
callback,
|
||||
allowRepeat: true,
|
||||
} as Action;
|
||||
|
||||
if (patterns.match(/^\(.*\)$/) !== null) {
|
||||
result.allowRepeat = false;
|
||||
patterns = patterns.slice(1, -1);
|
||||
}
|
||||
|
||||
result.patterns = patterns.split('|').map(part => {
|
||||
const pattern = {
|
||||
which: [],
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} as Pattern;
|
||||
|
||||
const keys = part.trim().split('+').map(x => x.trim().toLowerCase());
|
||||
for (const key of keys) {
|
||||
switch (key) {
|
||||
case 'ctrl': pattern.ctrl = true; break;
|
||||
case 'alt': pattern.alt = true; break;
|
||||
case 'shift': pattern.shift = true; break;
|
||||
default: pattern.which = keyCode(key).map(k => k.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ignoreElemens = ['input', 'textarea'];
|
||||
|
||||
function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean {
|
||||
const key = ev.code.toLowerCase();
|
||||
return patterns.some(pattern => pattern.which.includes(key) &&
|
||||
pattern.ctrl === ev.ctrlKey &&
|
||||
pattern.shift === ev.shiftKey &&
|
||||
pattern.alt === ev.altKey &&
|
||||
!ev.metaKey,
|
||||
);
|
||||
}
|
||||
|
||||
export const makeHotkey = (keymap: Keymap) => {
|
||||
const actions = parseKeymap(keymap);
|
||||
|
||||
return (ev: KeyboardEvent) => {
|
||||
if (document.activeElement) {
|
||||
if (ignoreElemens.some(el => document.activeElement!.matches(el))) return;
|
||||
if (document.activeElement.attributes['contenteditable']) return;
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
const matched = match(ev, action.patterns);
|
||||
|
||||
if (matched) {
|
||||
if (!action.allowRepeat && ev.repeat) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
action.callback(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
109
packages/frontend/src/scripts/hpml/block.ts
Normal file
109
packages/frontend/src/scripts/hpml/block.ts
Normal file
@ -0,0 +1,109 @@
|
||||
// blocks
|
||||
|
||||
export type BlockBase = {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type TextBlock = BlockBase & {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type SectionBlock = BlockBase & {
|
||||
type: 'section';
|
||||
title: string;
|
||||
children: (Block | VarBlock)[];
|
||||
};
|
||||
|
||||
export type ImageBlock = BlockBase & {
|
||||
type: 'image';
|
||||
fileId: string | null;
|
||||
};
|
||||
|
||||
export type ButtonBlock = BlockBase & {
|
||||
type: 'button';
|
||||
text: any;
|
||||
primary: boolean;
|
||||
action: string;
|
||||
content: string;
|
||||
event: string;
|
||||
message: string;
|
||||
var: string;
|
||||
fn: string;
|
||||
};
|
||||
|
||||
export type IfBlock = BlockBase & {
|
||||
type: 'if';
|
||||
var: string;
|
||||
children: Block[];
|
||||
};
|
||||
|
||||
export type TextareaBlock = BlockBase & {
|
||||
type: 'textarea';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PostBlock = BlockBase & {
|
||||
type: 'post';
|
||||
text: string;
|
||||
attachCanvasImage: boolean;
|
||||
canvasId: string;
|
||||
};
|
||||
|
||||
export type CanvasBlock = BlockBase & {
|
||||
type: 'canvas';
|
||||
name: string; // canvas id
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type NoteBlock = BlockBase & {
|
||||
type: 'note';
|
||||
detailed: boolean;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export type Block =
|
||||
TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock;
|
||||
|
||||
// variable blocks
|
||||
|
||||
export type VarBlockBase = BlockBase & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type NumberInputVarBlock = VarBlockBase & {
|
||||
type: 'numberInput';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type TextInputVarBlock = VarBlockBase & {
|
||||
type: 'textInput';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type SwitchVarBlock = VarBlockBase & {
|
||||
type: 'switch';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type RadioButtonVarBlock = VarBlockBase & {
|
||||
type: 'radioButton';
|
||||
title: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type CounterVarBlock = VarBlockBase & {
|
||||
type: 'counter';
|
||||
text: string;
|
||||
inc: number;
|
||||
};
|
||||
|
||||
export type VarBlock =
|
||||
NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock;
|
||||
|
||||
const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter'];
|
||||
export function isVarBlock(block: Block): block is VarBlock {
|
||||
return varBlock.includes(block.type);
|
||||
}
|
232
packages/frontend/src/scripts/hpml/evaluator.ts
Normal file
232
packages/frontend/src/scripts/hpml/evaluator.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.';
|
||||
import { version } from '@/config';
|
||||
import { AiScript, utils, values } from '@syuilo/aiscript';
|
||||
import { createAiScriptEnv } from '../aiscript/api';
|
||||
import { collectPageVars } from '../collect-page-vars';
|
||||
import { initHpmlLib, initAiLib } from './lib';
|
||||
import * as os from '@/os';
|
||||
import { markRaw, ref, Ref, unref } from 'vue';
|
||||
import { Expr, isLiteralValue, Variable } from './expr';
|
||||
|
||||
/**
|
||||
* Hpml evaluator
|
||||
*/
|
||||
export class Hpml {
|
||||
private variables: Variable[];
|
||||
private pageVars: PageVar[];
|
||||
private envVars: Record<keyof typeof envVarsDef, any>;
|
||||
public aiscript?: AiScript;
|
||||
public pageVarUpdatedCallback?: values.VFn;
|
||||
public canvases: Record<string, HTMLCanvasElement> = {};
|
||||
public vars: Ref<Record<string, any>> = ref({});
|
||||
public page: Record<string, any>;
|
||||
|
||||
private opts: {
|
||||
randomSeed: string; visitor?: any; url?: string;
|
||||
enableAiScript: boolean;
|
||||
};
|
||||
|
||||
constructor(page: Hpml['page'], opts: Hpml['opts']) {
|
||||
this.page = page;
|
||||
this.variables = this.page.variables;
|
||||
this.pageVars = collectPageVars(this.page.content);
|
||||
this.opts = opts;
|
||||
|
||||
if (this.opts.enableAiScript) {
|
||||
this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({
|
||||
storageKey: 'pages:' + this.page.id,
|
||||
}), ...initAiLib(this) }, {
|
||||
in: (q) => {
|
||||
return new Promise(ok => {
|
||||
os.inputText({
|
||||
title: q,
|
||||
}).then(({ canceled, result: a }) => {
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
},
|
||||
out: (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
log: (type, params) => {
|
||||
},
|
||||
}));
|
||||
|
||||
this.aiscript.scope.opts.onUpdated = (name, value) => {
|
||||
this.eval();
|
||||
};
|
||||
}
|
||||
|
||||
const date = new Date();
|
||||
|
||||
this.envVars = {
|
||||
AI: 'kawaii',
|
||||
VERSION: version,
|
||||
URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '',
|
||||
LOGIN: opts.visitor != null,
|
||||
NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '',
|
||||
USERNAME: opts.visitor ? opts.visitor.username : '',
|
||||
USERID: opts.visitor ? opts.visitor.id : '',
|
||||
NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
|
||||
FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
|
||||
FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
|
||||
IS_CAT: opts.visitor ? opts.visitor.isCat : false,
|
||||
SEED: opts.randomSeed ? opts.randomSeed : '',
|
||||
YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
|
||||
AISCRIPT_DISABLED: !this.opts.enableAiScript,
|
||||
NULL: null,
|
||||
};
|
||||
|
||||
this.eval();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public eval() {
|
||||
try {
|
||||
this.vars.value = this.evaluateVars();
|
||||
} catch (err) {
|
||||
//this.onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public interpolate(str: string) {
|
||||
if (str == null) return null;
|
||||
return str.replace(/{(.+?)}/g, match => {
|
||||
const v = unref(this.vars)[match.slice(1, -1).trim()];
|
||||
return v == null ? 'NULL' : v.toString();
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public callAiScript(fn: string) {
|
||||
try {
|
||||
if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []);
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public registerCanvas(id: string, canvas: any) {
|
||||
this.canvases[id] = canvas;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public updatePageVar(name: string, value: any) {
|
||||
const pageVar = this.pageVars.find(v => v.name === name);
|
||||
if (pageVar !== undefined) {
|
||||
pageVar.value = value;
|
||||
if (this.pageVarUpdatedCallback) {
|
||||
if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]);
|
||||
}
|
||||
} else {
|
||||
throw new HpmlError(`No such page var '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public updateRandomSeed(seed: string) {
|
||||
this.opts.randomSeed = seed;
|
||||
this.envVars.SEED = seed;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private _interpolateScope(str: string, scope: HpmlScope) {
|
||||
return str.replace(/{(.+?)}/g, match => {
|
||||
const v = scope.getState(match.slice(1, -1).trim());
|
||||
return v == null ? 'NULL' : v.toString();
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public evaluateVars(): Record<string, any> {
|
||||
const values: Record<string, any> = {};
|
||||
|
||||
for (const [k, v] of Object.entries(this.envVars)) {
|
||||
values[k] = v;
|
||||
}
|
||||
|
||||
for (const v of this.pageVars) {
|
||||
values[v.name] = v.value;
|
||||
}
|
||||
|
||||
for (const v of this.variables) {
|
||||
values[v.name] = this.evaluate(v, new HpmlScope([values]));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private evaluate(expr: Expr, scope: HpmlScope): any {
|
||||
if (isLiteralValue(expr)) {
|
||||
if (expr.type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (expr.type === 'number') {
|
||||
return parseInt((expr.value as any), 10);
|
||||
}
|
||||
|
||||
if (expr.type === 'text' || expr.type === 'multiLineText') {
|
||||
return this._interpolateScope(expr.value || '', scope);
|
||||
}
|
||||
|
||||
if (expr.type === 'textList') {
|
||||
return this._interpolateScope(expr.value || '', scope).trim().split('\n');
|
||||
}
|
||||
|
||||
if (expr.type === 'ref') {
|
||||
return scope.getState(expr.value);
|
||||
}
|
||||
|
||||
if (expr.type === 'aiScriptVar') {
|
||||
if (this.aiscript) {
|
||||
try {
|
||||
return utils.valToJs(this.aiscript.scope.get(expr.value));
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Define user function
|
||||
if (expr.type === 'fn') {
|
||||
return {
|
||||
slots: expr.value.slots.map(x => x.name),
|
||||
exec: (slotArg: Record<string, any>) => {
|
||||
return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id));
|
||||
},
|
||||
} as Fn;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Call user function
|
||||
if (expr.type.startsWith('fn:')) {
|
||||
const fnName = expr.type.split(':')[1];
|
||||
const fn = scope.getState(fnName);
|
||||
const args = {} as Record<string, any>;
|
||||
for (let i = 0; i < fn.slots.length; i++) {
|
||||
const name = fn.slots[i];
|
||||
args[name] = this.evaluate(expr.args[i], scope);
|
||||
}
|
||||
return fn.exec(args);
|
||||
}
|
||||
|
||||
if (expr.args === undefined) return null;
|
||||
|
||||
const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor);
|
||||
|
||||
// Call function
|
||||
const fnName = expr.type;
|
||||
const fn = (funcs as any)[fnName];
|
||||
if (fn == null) {
|
||||
throw new HpmlError(`No such function '${fnName}'`);
|
||||
} else {
|
||||
return fn(...expr.args.map(x => this.evaluate(x, scope)));
|
||||
}
|
||||
}
|
||||
}
|
79
packages/frontend/src/scripts/hpml/expr.ts
Normal file
79
packages/frontend/src/scripts/hpml/expr.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { literalDefs, Type } from '.';
|
||||
|
||||
export type ExprBase = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
// value
|
||||
|
||||
export type EmptyValue = ExprBase & {
|
||||
type: null;
|
||||
value: null;
|
||||
};
|
||||
|
||||
export type TextValue = ExprBase & {
|
||||
type: 'text';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type MultiLineTextValue = ExprBase & {
|
||||
type: 'multiLineText';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type TextListValue = ExprBase & {
|
||||
type: 'textList';
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type NumberValue = ExprBase & {
|
||||
type: 'number';
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type RefValue = ExprBase & {
|
||||
type: 'ref';
|
||||
value: string; // value is variable name
|
||||
};
|
||||
|
||||
export type AiScriptRefValue = ExprBase & {
|
||||
type: 'aiScriptVar';
|
||||
value: string; // value is variable name
|
||||
};
|
||||
|
||||
export type UserFnValue = ExprBase & {
|
||||
type: 'fn';
|
||||
value: UserFnInnerValue;
|
||||
};
|
||||
type UserFnInnerValue = {
|
||||
slots: {
|
||||
name: string;
|
||||
type: Type;
|
||||
}[];
|
||||
expression: Expr;
|
||||
};
|
||||
|
||||
export type Value =
|
||||
EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue;
|
||||
|
||||
export function isLiteralValue(expr: Expr): expr is Value {
|
||||
if (expr.type == null) return true;
|
||||
if (literalDefs[expr.type]) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// call function
|
||||
|
||||
export type CallFn = ExprBase & { // "fn:hoge" or string
|
||||
type: string;
|
||||
args: Expr[];
|
||||
value: null;
|
||||
};
|
||||
|
||||
// variable
|
||||
export type Variable = (Value | CallFn) & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
// expression
|
||||
export type Expr = Variable | Value | CallFn;
|
103
packages/frontend/src/scripts/hpml/index.ts
Normal file
103
packages/frontend/src/scripts/hpml/index.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Hpml
|
||||
*/
|
||||
|
||||
import autobind from 'autobind-decorator';
|
||||
import { Hpml } from './evaluator';
|
||||
import { funcDefs } from './lib';
|
||||
|
||||
export type Fn = {
|
||||
slots: string[];
|
||||
exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>;
|
||||
};
|
||||
|
||||
export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
|
||||
|
||||
export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
|
||||
text: { out: 'string', category: 'value', icon: 'ti ti-quote' },
|
||||
multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' },
|
||||
textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' },
|
||||
number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' },
|
||||
ref: { out: null, category: 'value', icon: 'fas fa-magic' },
|
||||
aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' },
|
||||
fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' },
|
||||
};
|
||||
|
||||
export const blockDefs = [
|
||||
...Object.entries(literalDefs).map(([k, v]) => ({
|
||||
type: k, out: v.out, category: v.category, icon: v.icon,
|
||||
})),
|
||||
...Object.entries(funcDefs).map(([k, v]) => ({
|
||||
type: k, out: v.out, category: v.category, icon: v.icon,
|
||||
})),
|
||||
];
|
||||
|
||||
export type PageVar = { name: string; value: any; type: Type; };
|
||||
|
||||
export const envVarsDef: Record<string, Type> = {
|
||||
AI: 'string',
|
||||
URL: 'string',
|
||||
VERSION: 'string',
|
||||
LOGIN: 'boolean',
|
||||
NAME: 'string',
|
||||
USERNAME: 'string',
|
||||
USERID: 'string',
|
||||
NOTES_COUNT: 'number',
|
||||
FOLLOWERS_COUNT: 'number',
|
||||
FOLLOWING_COUNT: 'number',
|
||||
IS_CAT: 'boolean',
|
||||
SEED: null,
|
||||
YMD: 'string',
|
||||
AISCRIPT_DISABLED: 'boolean',
|
||||
NULL: null,
|
||||
};
|
||||
|
||||
export class HpmlScope {
|
||||
private layerdStates: Record<string, any>[];
|
||||
public name: string;
|
||||
|
||||
constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) {
|
||||
this.layerdStates = layerdStates;
|
||||
this.name = name || 'anonymous';
|
||||
}
|
||||
|
||||
@autobind
|
||||
public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope {
|
||||
const layer = [states, ...this.layerdStates];
|
||||
return new HpmlScope(layer, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した名前の変数の値を取得します
|
||||
* @param name 変数名
|
||||
*/
|
||||
@autobind
|
||||
public getState(name: string): any {
|
||||
for (const later of this.layerdStates) {
|
||||
const state = later[name];
|
||||
if (state !== undefined) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
throw new HpmlError(
|
||||
`No such variable '${name}' in scope '${this.name}'`, {
|
||||
scope: this.layerdStates,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HpmlError extends Error {
|
||||
public info?: any;
|
||||
|
||||
constructor(message: string, info?: any) {
|
||||
super(message);
|
||||
|
||||
this.info = info;
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, HpmlError);
|
||||
}
|
||||
}
|
||||
}
|
247
packages/frontend/src/scripts/hpml/lib.ts
Normal file
247
packages/frontend/src/scripts/hpml/lib.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { Hpml } from './evaluator';
|
||||
import { values, utils } from '@syuilo/aiscript';
|
||||
import { Fn, HpmlScope } from '.';
|
||||
import { Expr } from './expr';
|
||||
import seedrandom from 'seedrandom';
|
||||
|
||||
/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color
|
||||
// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
|
||||
Chart.pluginService.register({
|
||||
beforeDraw: (chart, easing) => {
|
||||
if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
|
||||
const ctx = chart.chart.ctx;
|
||||
ctx.save();
|
||||
ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
|
||||
ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
export function initAiLib(hpml: Hpml) {
|
||||
return {
|
||||
'MkPages:updated': values.FN_NATIVE(([callback]) => {
|
||||
hpml.pageVarUpdatedCallback = (callback as values.VFn);
|
||||
}),
|
||||
'MkPages:get_canvas': values.FN_NATIVE(([id]) => {
|
||||
utils.assertString(id);
|
||||
const canvas = hpml.canvases[id.value];
|
||||
const ctx = canvas.getContext('2d');
|
||||
return values.OBJ(new Map([
|
||||
['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })],
|
||||
['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })],
|
||||
['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })],
|
||||
['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })],
|
||||
['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })],
|
||||
['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })],
|
||||
['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })],
|
||||
['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })],
|
||||
['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })],
|
||||
['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })],
|
||||
['close_path', values.FN_NATIVE(() => { ctx.closePath(); })],
|
||||
['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })],
|
||||
['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })],
|
||||
['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })],
|
||||
['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })],
|
||||
['fill', values.FN_NATIVE(() => { ctx.fill(); })],
|
||||
['stroke', values.FN_NATIVE(() => { ctx.stroke(); })],
|
||||
]));
|
||||
}),
|
||||
'MkPages:chart': values.FN_NATIVE(([id, opts]) => {
|
||||
/* TODO
|
||||
utils.assertString(id);
|
||||
utils.assertObject(opts);
|
||||
const canvas = hpml.canvases[id.value];
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
|
||||
Chart.defaults.color = '#555';
|
||||
const chart = new Chart(canvas, {
|
||||
type: opts.value.get('type').value,
|
||||
data: {
|
||||
labels: opts.value.get('labels').value.map(x => x.value),
|
||||
datasets: opts.value.get('datasets').value.map(x => ({
|
||||
label: x.value.has('label') ? x.value.get('label').value : '',
|
||||
data: x.value.get('data').value.map(x => x.value),
|
||||
pointRadius: 0,
|
||||
lineTension: 0,
|
||||
borderWidth: 2,
|
||||
borderColor: x.value.has('color') ? x.value.get('color') : color,
|
||||
backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(),
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
responsive: false,
|
||||
devicePixelRatio: 1.5,
|
||||
title: {
|
||||
display: opts.value.has('title'),
|
||||
text: opts.value.has('title') ? opts.value.get('title').value : '',
|
||||
fontSize: 14,
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
left: 32,
|
||||
right: 32,
|
||||
top: opts.value.has('title') ? 16 : 32,
|
||||
bottom: 16
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
}
|
||||
},
|
||||
tooltips: {
|
||||
enabled: false,
|
||||
},
|
||||
chartArea: {
|
||||
backgroundColor: '#fff'
|
||||
},
|
||||
...(opts.value.get('type').value === 'radar' ? {
|
||||
scale: {
|
||||
ticks: {
|
||||
display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false,
|
||||
min: opts.value.has('min') ? opts.value.get('min').value : undefined,
|
||||
max: opts.value.has('max') ? opts.value.get('max').value : undefined,
|
||||
maxTicksLimit: 8,
|
||||
},
|
||||
pointLabels: {
|
||||
fontSize: 12
|
||||
}
|
||||
}
|
||||
} : {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true,
|
||||
min: opts.value.has('min') ? opts.value.get('min').value : undefined,
|
||||
max: opts.value.has('max') ? opts.value.get('max').value : undefined,
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
*/
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = {
|
||||
if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' },
|
||||
for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' },
|
||||
not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
|
||||
or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
|
||||
and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' },
|
||||
add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' },
|
||||
subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' },
|
||||
multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' },
|
||||
divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' },
|
||||
mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' },
|
||||
round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' },
|
||||
eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' },
|
||||
notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' },
|
||||
gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' },
|
||||
lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' },
|
||||
gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' },
|
||||
ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' },
|
||||
strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' },
|
||||
strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' },
|
||||
strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
|
||||
strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
|
||||
join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
|
||||
stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' },
|
||||
numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' },
|
||||
splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' },
|
||||
pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' },
|
||||
listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' },
|
||||
rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
|
||||
dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
|
||||
seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' },
|
||||
random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
|
||||
dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
|
||||
seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' },
|
||||
randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' },
|
||||
dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' },
|
||||
seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' },
|
||||
DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping
|
||||
};
|
||||
|
||||
export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) {
|
||||
const date = new Date();
|
||||
const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
|
||||
|
||||
// SHOULD be fine to ignore since it's intended + function shape isn't defined
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const funcs: Record<string, Function> = {
|
||||
not: (a: boolean) => !a,
|
||||
or: (a: boolean, b: boolean) => a || b,
|
||||
and: (a: boolean, b: boolean) => a && b,
|
||||
eq: (a: any, b: any) => a === b,
|
||||
notEq: (a: any, b: any) => a !== b,
|
||||
gt: (a: number, b: number) => a > b,
|
||||
lt: (a: number, b: number) => a < b,
|
||||
gtEq: (a: number, b: number) => a >= b,
|
||||
ltEq: (a: number, b: number) => a <= b,
|
||||
if: (bool: boolean, a: any, b: any) => bool ? a : b,
|
||||
for: (times: number, fn: Fn) => {
|
||||
const result: any[] = [];
|
||||
for (let i = 0; i < times; i++) {
|
||||
result.push(fn.exec({
|
||||
[fn.slots[0]]: i + 1,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
add: (a: number, b: number) => a + b,
|
||||
subtract: (a: number, b: number) => a - b,
|
||||
multiply: (a: number, b: number) => a * b,
|
||||
divide: (a: number, b: number) => a / b,
|
||||
mod: (a: number, b: number) => a % b,
|
||||
round: (a: number) => Math.round(a),
|
||||
strLen: (a: string) => a.length,
|
||||
strPick: (a: string, b: number) => a[b - 1],
|
||||
strReplace: (a: string, b: string, c: string) => a.split(b).join(c),
|
||||
strReverse: (a: string) => a.split('').reverse().join(''),
|
||||
join: (texts: string[], separator: string) => texts.join(separator || ''),
|
||||
stringToNumber: (a: string) => parseInt(a),
|
||||
numberToString: (a: number) => a.toString(),
|
||||
splitStrByLine: (a: string) => a.split('\n'),
|
||||
pick: (list: any[], i: number) => list[i - 1],
|
||||
listLen: (list: any[]) => list.length,
|
||||
random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability,
|
||||
rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)),
|
||||
randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)],
|
||||
dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability,
|
||||
dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)),
|
||||
dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)],
|
||||
seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability,
|
||||
seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)),
|
||||
seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)],
|
||||
DRPWPM: (list: string[]) => {
|
||||
const xs: any[] = [];
|
||||
let totalFactor = 0;
|
||||
for (const x of list) {
|
||||
const parts = x.split(' ');
|
||||
const factor = parseInt(parts.pop()!, 10);
|
||||
const text = parts.join(' ');
|
||||
totalFactor += factor;
|
||||
xs.push({ factor, text });
|
||||
}
|
||||
const r = seedrandom(`${day}:${expr.id}`)() * totalFactor;
|
||||
let stackedFactor = 0;
|
||||
for (const x of xs) {
|
||||
if (r >= stackedFactor && r <= stackedFactor + x.factor) {
|
||||
return x.text;
|
||||
} else {
|
||||
stackedFactor += x.factor;
|
||||
}
|
||||
}
|
||||
return xs[0].text;
|
||||
},
|
||||
};
|
||||
|
||||
return funcs;
|
||||
}
|
191
packages/frontend/src/scripts/hpml/type-checker.ts
Normal file
191
packages/frontend/src/scripts/hpml/type-checker.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { isLiteralValue } from './expr';
|
||||
import { funcDefs } from './lib';
|
||||
import { envVarsDef } from '.';
|
||||
import type { Type, PageVar } from '.';
|
||||
import type { Expr, Variable } from './expr';
|
||||
|
||||
type TypeError = {
|
||||
arg: number;
|
||||
expect: Type;
|
||||
actual: Type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hpml type checker
|
||||
*/
|
||||
export class HpmlTypeChecker {
|
||||
public variables: Variable[];
|
||||
public pageVars: PageVar[];
|
||||
|
||||
constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) {
|
||||
this.variables = variables;
|
||||
this.pageVars = pageVars;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public typeCheck(v: Expr): TypeError | null {
|
||||
if (isLiteralValue(v)) return null;
|
||||
|
||||
const def = funcDefs[v.type || ''];
|
||||
if (def == null) {
|
||||
throw new Error('Unknown type: ' + v.type);
|
||||
}
|
||||
|
||||
const generic: Type[] = [];
|
||||
|
||||
for (let i = 0; i < def.in.length; i++) {
|
||||
const arg = def.in[i];
|
||||
const type = this.infer(v.args[i]);
|
||||
if (type === null) continue;
|
||||
|
||||
if (typeof arg === 'number') {
|
||||
if (generic[arg] === undefined) {
|
||||
generic[arg] = type;
|
||||
} else if (type !== generic[arg]) {
|
||||
return {
|
||||
arg: i,
|
||||
expect: generic[arg],
|
||||
actual: type,
|
||||
};
|
||||
}
|
||||
} else if (type !== arg) {
|
||||
return {
|
||||
arg: i,
|
||||
expect: arg,
|
||||
actual: type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getExpectedType(v: Expr, slot: number): Type {
|
||||
const def = funcDefs[v.type || ''];
|
||||
if (def == null) {
|
||||
throw new Error('Unknown type: ' + v.type);
|
||||
}
|
||||
|
||||
const generic: Type[] = [];
|
||||
|
||||
for (let i = 0; i < def.in.length; i++) {
|
||||
const arg = def.in[i];
|
||||
const type = this.infer(v.args[i]);
|
||||
if (type === null) continue;
|
||||
|
||||
if (typeof arg === 'number') {
|
||||
if (generic[arg] === undefined) {
|
||||
generic[arg] = type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof def.in[slot] === 'number') {
|
||||
return generic[def.in[slot]] ?? null;
|
||||
} else {
|
||||
return def.in[slot];
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public infer(v: Expr): Type {
|
||||
if (v.type === null) return null;
|
||||
if (v.type === 'text') return 'string';
|
||||
if (v.type === 'multiLineText') return 'string';
|
||||
if (v.type === 'textList') return 'stringArray';
|
||||
if (v.type === 'number') return 'number';
|
||||
if (v.type === 'ref') {
|
||||
const variable = this.variables.find(va => va.name === v.value);
|
||||
if (variable) {
|
||||
return this.infer(variable);
|
||||
}
|
||||
|
||||
const pageVar = this.pageVars.find(va => va.name === v.value);
|
||||
if (pageVar) {
|
||||
return pageVar.type;
|
||||
}
|
||||
|
||||
const envVar = envVarsDef[v.value || ''];
|
||||
if (envVar !== undefined) {
|
||||
return envVar;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (v.type === 'aiScriptVar') return null;
|
||||
if (v.type === 'fn') return null; // todo
|
||||
if (v.type.startsWith('fn:')) return null; // todo
|
||||
|
||||
const generic: Type[] = [];
|
||||
|
||||
const def = funcDefs[v.type];
|
||||
|
||||
for (let i = 0; i < def.in.length; i++) {
|
||||
const arg = def.in[i];
|
||||
if (typeof arg === 'number') {
|
||||
const type = this.infer(v.args[i]);
|
||||
|
||||
if (generic[arg] === undefined) {
|
||||
generic[arg] = type;
|
||||
} else {
|
||||
if (type !== generic[arg]) {
|
||||
generic[arg] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof def.out === 'number') {
|
||||
return generic[def.out];
|
||||
} else {
|
||||
return def.out;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getVarByName(name: string): Variable {
|
||||
const v = this.variables.find(x => x.name === name);
|
||||
if (v !== undefined) {
|
||||
return v;
|
||||
} else {
|
||||
throw new Error(`No such variable '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getVarsByType(type: Type): Variable[] {
|
||||
if (type == null) return this.variables;
|
||||
return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getEnvVarsByType(type: Type): string[] {
|
||||
if (type == null) return Object.keys(envVarsDef);
|
||||
return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public getPageVarsByType(type: Type): string[] {
|
||||
if (type == null) return this.pageVars.map(v => v.name);
|
||||
return this.pageVars.filter(v => type === v.type).map(v => v.name);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public isUsedName(name: string) {
|
||||
if (this.variables.some(v => v.name === name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.pageVars.some(v => v.name === name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (envVarsDef[name]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
29
packages/frontend/src/scripts/i18n.ts
Normal file
29
packages/frontend/src/scripts/i18n.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export class I18n<T extends Record<string, any>> {
|
||||
public ts: T;
|
||||
|
||||
constructor(locale: T) {
|
||||
this.ts = locale;
|
||||
|
||||
//#region BIND
|
||||
this.t = this.t.bind(this);
|
||||
//#endregion
|
||||
}
|
||||
|
||||
// string にしているのは、ドット区切りでのパス指定を許可するため
|
||||
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
|
||||
public t(key: string, args?: Record<string, string | number>): string {
|
||||
try {
|
||||
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
|
||||
|
||||
if (args) {
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
str = str.replace(`{${k}}`, v.toString());
|
||||
}
|
||||
}
|
||||
return str;
|
||||
} catch (err) {
|
||||
console.warn(`missing localization '${key}'`);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
36
packages/frontend/src/scripts/idb-proxy.ts
Normal file
36
packages/frontend/src/scripts/idb-proxy.ts
Normal file
@ -0,0 +1,36 @@
|
||||
// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
|
||||
// indexedDBが使えない環境ではlocalStorageを使う
|
||||
import {
|
||||
get as iget,
|
||||
set as iset,
|
||||
del as idel,
|
||||
} from 'idb-keyval';
|
||||
|
||||
const fallbackName = (key: string) => `idbfallback::${key}`;
|
||||
|
||||
let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true;
|
||||
|
||||
if (idbAvailable) {
|
||||
iset('idb-test', 'test').catch(err => {
|
||||
console.error('idb error', err);
|
||||
console.error('indexedDB is unavailable. It will use localStorage.');
|
||||
idbAvailable = false;
|
||||
});
|
||||
} else {
|
||||
console.error('indexedDB is unavailable. It will use localStorage.');
|
||||
}
|
||||
|
||||
export async function get(key: string) {
|
||||
if (idbAvailable) return iget(key);
|
||||
return JSON.parse(localStorage.getItem(fallbackName(key)));
|
||||
}
|
||||
|
||||
export async function set(key: string, val: any) {
|
||||
if (idbAvailable) return iset(key, val);
|
||||
return localStorage.setItem(fallbackName(key), JSON.stringify(val));
|
||||
}
|
||||
|
||||
export async function del(key: string) {
|
||||
if (idbAvailable) return idel(key);
|
||||
return localStorage.removeItem(fallbackName(key));
|
||||
}
|
13
packages/frontend/src/scripts/initialize-sw.ts
Normal file
13
packages/frontend/src/scripts/initialize-sw.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { lang } from '@/config';
|
||||
|
||||
export async function initializeSw() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' });
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.active?.postMessage({
|
||||
msg: 'initialize',
|
||||
lang,
|
||||
});
|
||||
});
|
||||
}
|
3
packages/frontend/src/scripts/is-device-darkmode.ts
Normal file
3
packages/frontend/src/scripts/is-device-darkmode.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isDeviceDarkmode() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
33
packages/frontend/src/scripts/keycode.ts
Normal file
33
packages/frontend/src/scripts/keycode.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export default (input: string): string[] => {
|
||||
if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) {
|
||||
const codes = aliases[input];
|
||||
return Array.isArray(codes) ? codes : [codes];
|
||||
} else {
|
||||
return [input];
|
||||
}
|
||||
};
|
||||
|
||||
export const aliases = {
|
||||
'esc': 'Escape',
|
||||
'enter': ['Enter', 'NumpadEnter'],
|
||||
'up': 'ArrowUp',
|
||||
'down': 'ArrowDown',
|
||||
'left': 'ArrowLeft',
|
||||
'right': 'ArrowRight',
|
||||
'plus': ['NumpadAdd', 'Semicolon'],
|
||||
};
|
||||
|
||||
/*!
|
||||
* Programmatically add the following
|
||||
*/
|
||||
|
||||
// lower case chars
|
||||
for (let i = 97; i < 123; i++) {
|
||||
const char = String.fromCharCode(i);
|
||||
aliases[char] = `Key${char.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// numbers
|
||||
for (let i = 0; i < 10; i++) {
|
||||
aliases[i] = [`Numpad${i}`, `Digit${i}`];
|
||||
}
|
666
packages/frontend/src/scripts/langmap.ts
Normal file
666
packages/frontend/src/scripts/langmap.ts
Normal file
@ -0,0 +1,666 @@
|
||||
// TODO: sharedに置いてバックエンドのと統合したい
|
||||
export const langmap = {
|
||||
'ach': {
|
||||
nativeName: 'Lwo',
|
||||
},
|
||||
'ady': {
|
||||
nativeName: 'Адыгэбзэ',
|
||||
},
|
||||
'af': {
|
||||
nativeName: 'Afrikaans',
|
||||
},
|
||||
'af-NA': {
|
||||
nativeName: 'Afrikaans (Namibia)',
|
||||
},
|
||||
'af-ZA': {
|
||||
nativeName: 'Afrikaans (South Africa)',
|
||||
},
|
||||
'ak': {
|
||||
nativeName: 'Tɕɥi',
|
||||
},
|
||||
'ar': {
|
||||
nativeName: 'العربية',
|
||||
},
|
||||
'ar-AR': {
|
||||
nativeName: 'العربية',
|
||||
},
|
||||
'ar-MA': {
|
||||
nativeName: 'العربية',
|
||||
},
|
||||
'ar-SA': {
|
||||
nativeName: 'العربية (السعودية)',
|
||||
},
|
||||
'ay-BO': {
|
||||
nativeName: 'Aymar aru',
|
||||
},
|
||||
'az': {
|
||||
nativeName: 'Azərbaycan dili',
|
||||
},
|
||||
'az-AZ': {
|
||||
nativeName: 'Azərbaycan dili',
|
||||
},
|
||||
'be-BY': {
|
||||
nativeName: 'Беларуская',
|
||||
},
|
||||
'bg': {
|
||||
nativeName: 'Български',
|
||||
},
|
||||
'bg-BG': {
|
||||
nativeName: 'Български',
|
||||
},
|
||||
'bn': {
|
||||
nativeName: 'বাংলা',
|
||||
},
|
||||
'bn-IN': {
|
||||
nativeName: 'বাংলা (ভারত)',
|
||||
},
|
||||
'bn-BD': {
|
||||
nativeName: 'বাংলা(বাংলাদেশ)',
|
||||
},
|
||||
'br': {
|
||||
nativeName: 'Brezhoneg',
|
||||
},
|
||||
'bs-BA': {
|
||||
nativeName: 'Bosanski',
|
||||
},
|
||||
'ca': {
|
||||
nativeName: 'Català',
|
||||
},
|
||||
'ca-ES': {
|
||||
nativeName: 'Català',
|
||||
},
|
||||
'cak': {
|
||||
nativeName: 'Maya Kaqchikel',
|
||||
},
|
||||
'ck-US': {
|
||||
nativeName: 'ᏣᎳᎩ (tsalagi)',
|
||||
},
|
||||
'cs': {
|
||||
nativeName: 'Čeština',
|
||||
},
|
||||
'cs-CZ': {
|
||||
nativeName: 'Čeština',
|
||||
},
|
||||
'cy': {
|
||||
nativeName: 'Cymraeg',
|
||||
},
|
||||
'cy-GB': {
|
||||
nativeName: 'Cymraeg',
|
||||
},
|
||||
'da': {
|
||||
nativeName: 'Dansk',
|
||||
},
|
||||
'da-DK': {
|
||||
nativeName: 'Dansk',
|
||||
},
|
||||
'de': {
|
||||
nativeName: 'Deutsch',
|
||||
},
|
||||
'de-AT': {
|
||||
nativeName: 'Deutsch (Österreich)',
|
||||
},
|
||||
'de-DE': {
|
||||
nativeName: 'Deutsch (Deutschland)',
|
||||
},
|
||||
'de-CH': {
|
||||
nativeName: 'Deutsch (Schweiz)',
|
||||
},
|
||||
'dsb': {
|
||||
nativeName: 'Dolnoserbšćina',
|
||||
},
|
||||
'el': {
|
||||
nativeName: 'Ελληνικά',
|
||||
},
|
||||
'el-GR': {
|
||||
nativeName: 'Ελληνικά',
|
||||
},
|
||||
'en': {
|
||||
nativeName: 'English',
|
||||
},
|
||||
'en-GB': {
|
||||
nativeName: 'English (UK)',
|
||||
},
|
||||
'en-AU': {
|
||||
nativeName: 'English (Australia)',
|
||||
},
|
||||
'en-CA': {
|
||||
nativeName: 'English (Canada)',
|
||||
},
|
||||
'en-IE': {
|
||||
nativeName: 'English (Ireland)',
|
||||
},
|
||||
'en-IN': {
|
||||
nativeName: 'English (India)',
|
||||
},
|
||||
'en-PI': {
|
||||
nativeName: 'English (Pirate)',
|
||||
},
|
||||
'en-SG': {
|
||||
nativeName: 'English (Singapore)',
|
||||
},
|
||||
'en-UD': {
|
||||
nativeName: 'English (Upside Down)',
|
||||
},
|
||||
'en-US': {
|
||||
nativeName: 'English (US)',
|
||||
},
|
||||
'en-ZA': {
|
||||
nativeName: 'English (South Africa)',
|
||||
},
|
||||
'en@pirate': {
|
||||
nativeName: 'English (Pirate)',
|
||||
},
|
||||
'eo': {
|
||||
nativeName: 'Esperanto',
|
||||
},
|
||||
'eo-EO': {
|
||||
nativeName: 'Esperanto',
|
||||
},
|
||||
'es': {
|
||||
nativeName: 'Español',
|
||||
},
|
||||
'es-AR': {
|
||||
nativeName: 'Español (Argentine)',
|
||||
},
|
||||
'es-419': {
|
||||
nativeName: 'Español (Latinoamérica)',
|
||||
},
|
||||
'es-CL': {
|
||||
nativeName: 'Español (Chile)',
|
||||
},
|
||||
'es-CO': {
|
||||
nativeName: 'Español (Colombia)',
|
||||
},
|
||||
'es-EC': {
|
||||
nativeName: 'Español (Ecuador)',
|
||||
},
|
||||
'es-ES': {
|
||||
nativeName: 'Español (España)',
|
||||
},
|
||||
'es-LA': {
|
||||
nativeName: 'Español (Latinoamérica)',
|
||||
},
|
||||
'es-NI': {
|
||||
nativeName: 'Español (Nicaragua)',
|
||||
},
|
||||
'es-MX': {
|
||||
nativeName: 'Español (México)',
|
||||
},
|
||||
'es-US': {
|
||||
nativeName: 'Español (Estados Unidos)',
|
||||
},
|
||||
'es-VE': {
|
||||
nativeName: 'Español (Venezuela)',
|
||||
},
|
||||
'et': {
|
||||
nativeName: 'eesti keel',
|
||||
},
|
||||
'et-EE': {
|
||||
nativeName: 'Eesti (Estonia)',
|
||||
},
|
||||
'eu': {
|
||||
nativeName: 'Euskara',
|
||||
},
|
||||
'eu-ES': {
|
||||
nativeName: 'Euskara',
|
||||
},
|
||||
'fa': {
|
||||
nativeName: 'فارسی',
|
||||
},
|
||||
'fa-IR': {
|
||||
nativeName: 'فارسی',
|
||||
},
|
||||
'fb-LT': {
|
||||
nativeName: 'Leet Speak',
|
||||
},
|
||||
'ff': {
|
||||
nativeName: 'Fulah',
|
||||
},
|
||||
'fi': {
|
||||
nativeName: 'Suomi',
|
||||
},
|
||||
'fi-FI': {
|
||||
nativeName: 'Suomi',
|
||||
},
|
||||
'fo': {
|
||||
nativeName: 'Føroyskt',
|
||||
},
|
||||
'fo-FO': {
|
||||
nativeName: 'Føroyskt (Færeyjar)',
|
||||
},
|
||||
'fr': {
|
||||
nativeName: 'Français',
|
||||
},
|
||||
'fr-CA': {
|
||||
nativeName: 'Français (Canada)',
|
||||
},
|
||||
'fr-FR': {
|
||||
nativeName: 'Français (France)',
|
||||
},
|
||||
'fr-BE': {
|
||||
nativeName: 'Français (Belgique)',
|
||||
},
|
||||
'fr-CH': {
|
||||
nativeName: 'Français (Suisse)',
|
||||
},
|
||||
'fy-NL': {
|
||||
nativeName: 'Frysk',
|
||||
},
|
||||
'ga': {
|
||||
nativeName: 'Gaeilge',
|
||||
},
|
||||
'ga-IE': {
|
||||
nativeName: 'Gaeilge',
|
||||
},
|
||||
'gd': {
|
||||
nativeName: 'Gàidhlig',
|
||||
},
|
||||
'gl': {
|
||||
nativeName: 'Galego',
|
||||
},
|
||||
'gl-ES': {
|
||||
nativeName: 'Galego',
|
||||
},
|
||||
'gn-PY': {
|
||||
nativeName: 'Avañe\'ẽ',
|
||||
},
|
||||
'gu-IN': {
|
||||
nativeName: 'ગુજરાતી',
|
||||
},
|
||||
'gv': {
|
||||
nativeName: 'Gaelg',
|
||||
},
|
||||
'gx-GR': {
|
||||
nativeName: 'Ἑλληνική ἀρχαία',
|
||||
},
|
||||
'he': {
|
||||
nativeName: 'עברית',
|
||||
},
|
||||
'he-IL': {
|
||||
nativeName: 'עברית',
|
||||
},
|
||||
'hi': {
|
||||
nativeName: 'हिन्दी',
|
||||
},
|
||||
'hi-IN': {
|
||||
nativeName: 'हिन्दी',
|
||||
},
|
||||
'hr': {
|
||||
nativeName: 'Hrvatski',
|
||||
},
|
||||
'hr-HR': {
|
||||
nativeName: 'Hrvatski',
|
||||
},
|
||||
'hsb': {
|
||||
nativeName: 'Hornjoserbšćina',
|
||||
},
|
||||
'ht': {
|
||||
nativeName: 'Kreyòl',
|
||||
},
|
||||
'hu': {
|
||||
nativeName: 'Magyar',
|
||||
},
|
||||
'hu-HU': {
|
||||
nativeName: 'Magyar',
|
||||
},
|
||||
'hy': {
|
||||
nativeName: 'Հայերեն',
|
||||
},
|
||||
'hy-AM': {
|
||||
nativeName: 'Հայերեն (Հայաստան)',
|
||||
},
|
||||
'id': {
|
||||
nativeName: 'Bahasa Indonesia',
|
||||
},
|
||||
'id-ID': {
|
||||
nativeName: 'Bahasa Indonesia',
|
||||
},
|
||||
'is': {
|
||||
nativeName: 'Íslenska',
|
||||
},
|
||||
'is-IS': {
|
||||
nativeName: 'Íslenska (Iceland)',
|
||||
},
|
||||
'it': {
|
||||
nativeName: 'Italiano',
|
||||
},
|
||||
'it-IT': {
|
||||
nativeName: 'Italiano',
|
||||
},
|
||||
'ja': {
|
||||
nativeName: '日本語',
|
||||
},
|
||||
'ja-JP': {
|
||||
nativeName: '日本語 (日本)',
|
||||
},
|
||||
'jv-ID': {
|
||||
nativeName: 'Basa Jawa',
|
||||
},
|
||||
'ka-GE': {
|
||||
nativeName: 'ქართული',
|
||||
},
|
||||
'kk-KZ': {
|
||||
nativeName: 'Қазақша',
|
||||
},
|
||||
'km': {
|
||||
nativeName: 'ភាសាខ្មែរ',
|
||||
},
|
||||
'kl': {
|
||||
nativeName: 'kalaallisut',
|
||||
},
|
||||
'km-KH': {
|
||||
nativeName: 'ភាសាខ្មែរ',
|
||||
},
|
||||
'kab': {
|
||||
nativeName: 'Taqbaylit',
|
||||
},
|
||||
'kn': {
|
||||
nativeName: 'ಕನ್ನಡ',
|
||||
},
|
||||
'kn-IN': {
|
||||
nativeName: 'ಕನ್ನಡ (India)',
|
||||
},
|
||||
'ko': {
|
||||
nativeName: '한국어',
|
||||
},
|
||||
'ko-KR': {
|
||||
nativeName: '한국어 (한국)',
|
||||
},
|
||||
'ku-TR': {
|
||||
nativeName: 'Kurdî',
|
||||
},
|
||||
'kw': {
|
||||
nativeName: 'Kernewek',
|
||||
},
|
||||
'la': {
|
||||
nativeName: 'Latin',
|
||||
},
|
||||
'la-VA': {
|
||||
nativeName: 'Latin',
|
||||
},
|
||||
'lb': {
|
||||
nativeName: 'Lëtzebuergesch',
|
||||
},
|
||||
'li-NL': {
|
||||
nativeName: 'Lèmbörgs',
|
||||
},
|
||||
'lt': {
|
||||
nativeName: 'Lietuvių',
|
||||
},
|
||||
'lt-LT': {
|
||||
nativeName: 'Lietuvių',
|
||||
},
|
||||
'lv': {
|
||||
nativeName: 'Latviešu',
|
||||
},
|
||||
'lv-LV': {
|
||||
nativeName: 'Latviešu',
|
||||
},
|
||||
'mai': {
|
||||
nativeName: 'मैथिली, মৈথিলী',
|
||||
},
|
||||
'mg-MG': {
|
||||
nativeName: 'Malagasy',
|
||||
},
|
||||
'mk': {
|
||||
nativeName: 'Македонски',
|
||||
},
|
||||
'mk-MK': {
|
||||
nativeName: 'Македонски (Македонски)',
|
||||
},
|
||||
'ml': {
|
||||
nativeName: 'മലയാളം',
|
||||
},
|
||||
'ml-IN': {
|
||||
nativeName: 'മലയാളം',
|
||||
},
|
||||
'mn-MN': {
|
||||
nativeName: 'Монгол',
|
||||
},
|
||||
'mr': {
|
||||
nativeName: 'मराठी',
|
||||
},
|
||||
'mr-IN': {
|
||||
nativeName: 'मराठी',
|
||||
},
|
||||
'ms': {
|
||||
nativeName: 'Bahasa Melayu',
|
||||
},
|
||||
'ms-MY': {
|
||||
nativeName: 'Bahasa Melayu',
|
||||
},
|
||||
'mt': {
|
||||
nativeName: 'Malti',
|
||||
},
|
||||
'mt-MT': {
|
||||
nativeName: 'Malti',
|
||||
},
|
||||
'my': {
|
||||
nativeName: 'ဗမာစကာ',
|
||||
},
|
||||
'no': {
|
||||
nativeName: 'Norsk',
|
||||
},
|
||||
'nb': {
|
||||
nativeName: 'Norsk (bokmål)',
|
||||
},
|
||||
'nb-NO': {
|
||||
nativeName: 'Norsk (bokmål)',
|
||||
},
|
||||
'ne': {
|
||||
nativeName: 'नेपाली',
|
||||
},
|
||||
'ne-NP': {
|
||||
nativeName: 'नेपाली',
|
||||
},
|
||||
'nl': {
|
||||
nativeName: 'Nederlands',
|
||||
},
|
||||
'nl-BE': {
|
||||
nativeName: 'Nederlands (België)',
|
||||
},
|
||||
'nl-NL': {
|
||||
nativeName: 'Nederlands (Nederland)',
|
||||
},
|
||||
'nn-NO': {
|
||||
nativeName: 'Norsk (nynorsk)',
|
||||
},
|
||||
'oc': {
|
||||
nativeName: 'Occitan',
|
||||
},
|
||||
'or-IN': {
|
||||
nativeName: 'ଓଡ଼ିଆ',
|
||||
},
|
||||
'pa': {
|
||||
nativeName: 'ਪੰਜਾਬੀ',
|
||||
},
|
||||
'pa-IN': {
|
||||
nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
|
||||
},
|
||||
'pl': {
|
||||
nativeName: 'Polski',
|
||||
},
|
||||
'pl-PL': {
|
||||
nativeName: 'Polski',
|
||||
},
|
||||
'ps-AF': {
|
||||
nativeName: 'پښتو',
|
||||
},
|
||||
'pt': {
|
||||
nativeName: 'Português',
|
||||
},
|
||||
'pt-BR': {
|
||||
nativeName: 'Português (Brasil)',
|
||||
},
|
||||
'pt-PT': {
|
||||
nativeName: 'Português (Portugal)',
|
||||
},
|
||||
'qu-PE': {
|
||||
nativeName: 'Qhichwa',
|
||||
},
|
||||
'rm-CH': {
|
||||
nativeName: 'Rumantsch',
|
||||
},
|
||||
'ro': {
|
||||
nativeName: 'Română',
|
||||
},
|
||||
'ro-RO': {
|
||||
nativeName: 'Română',
|
||||
},
|
||||
'ru': {
|
||||
nativeName: 'Русский',
|
||||
},
|
||||
'ru-RU': {
|
||||
nativeName: 'Русский',
|
||||
},
|
||||
'sa-IN': {
|
||||
nativeName: 'संस्कृतम्',
|
||||
},
|
||||
'se-NO': {
|
||||
nativeName: 'Davvisámegiella',
|
||||
},
|
||||
'sh': {
|
||||
nativeName: 'српскохрватски',
|
||||
},
|
||||
'si-LK': {
|
||||
nativeName: 'සිංහල',
|
||||
},
|
||||
'sk': {
|
||||
nativeName: 'Slovenčina',
|
||||
},
|
||||
'sk-SK': {
|
||||
nativeName: 'Slovenčina (Slovakia)',
|
||||
},
|
||||
'sl': {
|
||||
nativeName: 'Slovenščina',
|
||||
},
|
||||
'sl-SI': {
|
||||
nativeName: 'Slovenščina',
|
||||
},
|
||||
'so-SO': {
|
||||
nativeName: 'Soomaaliga',
|
||||
},
|
||||
'sq': {
|
||||
nativeName: 'Shqip',
|
||||
},
|
||||
'sq-AL': {
|
||||
nativeName: 'Shqip',
|
||||
},
|
||||
'sr': {
|
||||
nativeName: 'Српски',
|
||||
},
|
||||
'sr-RS': {
|
||||
nativeName: 'Српски (Serbia)',
|
||||
},
|
||||
'su': {
|
||||
nativeName: 'Basa Sunda',
|
||||
},
|
||||
'sv': {
|
||||
nativeName: 'Svenska',
|
||||
},
|
||||
'sv-SE': {
|
||||
nativeName: 'Svenska',
|
||||
},
|
||||
'sw': {
|
||||
nativeName: 'Kiswahili',
|
||||
},
|
||||
'sw-KE': {
|
||||
nativeName: 'Kiswahili',
|
||||
},
|
||||
'ta': {
|
||||
nativeName: 'தமிழ்',
|
||||
},
|
||||
'ta-IN': {
|
||||
nativeName: 'தமிழ்',
|
||||
},
|
||||
'te': {
|
||||
nativeName: 'తెలుగు',
|
||||
},
|
||||
'te-IN': {
|
||||
nativeName: 'తెలుగు',
|
||||
},
|
||||
'tg': {
|
||||
nativeName: 'забо́ни тоҷикӣ́',
|
||||
},
|
||||
'tg-TJ': {
|
||||
nativeName: 'тоҷикӣ',
|
||||
},
|
||||
'th': {
|
||||
nativeName: 'ภาษาไทย',
|
||||
},
|
||||
'th-TH': {
|
||||
nativeName: 'ภาษาไทย (ประเทศไทย)',
|
||||
},
|
||||
'fil': {
|
||||
nativeName: 'Filipino',
|
||||
},
|
||||
'tlh': {
|
||||
nativeName: 'tlhIngan-Hol',
|
||||
},
|
||||
'tr': {
|
||||
nativeName: 'Türkçe',
|
||||
},
|
||||
'tr-TR': {
|
||||
nativeName: 'Türkçe',
|
||||
},
|
||||
'tt-RU': {
|
||||
nativeName: 'татарча',
|
||||
},
|
||||
'uk': {
|
||||
nativeName: 'Українська',
|
||||
},
|
||||
'uk-UA': {
|
||||
nativeName: 'Українська',
|
||||
},
|
||||
'ur': {
|
||||
nativeName: 'اردو',
|
||||
},
|
||||
'ur-PK': {
|
||||
nativeName: 'اردو',
|
||||
},
|
||||
'uz': {
|
||||
nativeName: 'O\'zbek',
|
||||
},
|
||||
'uz-UZ': {
|
||||
nativeName: 'O\'zbek',
|
||||
},
|
||||
'vi': {
|
||||
nativeName: 'Tiếng Việt',
|
||||
},
|
||||
'vi-VN': {
|
||||
nativeName: 'Tiếng Việt',
|
||||
},
|
||||
'xh-ZA': {
|
||||
nativeName: 'isiXhosa',
|
||||
},
|
||||
'yi': {
|
||||
nativeName: 'ייִדיש',
|
||||
},
|
||||
'yi-DE': {
|
||||
nativeName: 'ייִדיש (German)',
|
||||
},
|
||||
'zh': {
|
||||
nativeName: '中文',
|
||||
},
|
||||
'zh-Hans': {
|
||||
nativeName: '中文简体',
|
||||
},
|
||||
'zh-Hant': {
|
||||
nativeName: '中文繁體',
|
||||
},
|
||||
'zh-CN': {
|
||||
nativeName: '中文(中国大陆)',
|
||||
},
|
||||
'zh-HK': {
|
||||
nativeName: '中文(香港)',
|
||||
},
|
||||
'zh-SG': {
|
||||
nativeName: '中文(新加坡)',
|
||||
},
|
||||
'zh-TW': {
|
||||
nativeName: '中文(台灣)',
|
||||
},
|
||||
'zu-ZA': {
|
||||
nativeName: 'isiZulu',
|
||||
},
|
||||
};
|
11
packages/frontend/src/scripts/login-id.ts
Normal file
11
packages/frontend/src/scripts/login-id.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function getUrlWithLoginId(url: string, loginId: string) {
|
||||
const u = new URL(url, origin);
|
||||
u.searchParams.append('loginId', loginId);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export function getUrlWithoutLoginId(url: string) {
|
||||
const u = new URL(url);
|
||||
u.searchParams.delete('loginId');
|
||||
return u.toString();
|
||||
}
|
36
packages/frontend/src/scripts/lookup-user.ts
Normal file
36
packages/frontend/src/scripts/lookup-user.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
|
||||
export async function lookupUser() {
|
||||
const { canceled, result } = await os.inputText({
|
||||
title: i18n.ts.usernameOrUserId,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const show = (user) => {
|
||||
os.pageWindow(`/user-info/${user.id}`);
|
||||
};
|
||||
|
||||
const usernamePromise = os.api('users/show', Acct.parse(result));
|
||||
const idPromise = os.api('users/show', { userId: result });
|
||||
let _notFound = false;
|
||||
const notFound = () => {
|
||||
if (_notFound) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.noSuchUser,
|
||||
});
|
||||
} else {
|
||||
_notFound = true;
|
||||
}
|
||||
};
|
||||
usernamePromise.then(show).catch(err => {
|
||||
if (err.code === 'NO_SUCH_USER') {
|
||||
notFound();
|
||||
}
|
||||
});
|
||||
idPromise.then(show).catch(err => {
|
||||
notFound();
|
||||
});
|
||||
}
|
15
packages/frontend/src/scripts/media-proxy.ts
Normal file
15
packages/frontend/src/scripts/media-proxy.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { query } from '@/scripts/url';
|
||||
import { url } from '@/config';
|
||||
|
||||
export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
|
||||
return `${url}/proxy/image.webp?${query({
|
||||
url: imageUrl,
|
||||
fallback: '1',
|
||||
...(type ? { [type]: '1' } : {}),
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
|
||||
if (imageUrl == null) return null;
|
||||
return getProxiedImageUrl(imageUrl, type);
|
||||
}
|
1
packages/frontend/src/scripts/mfm-tags.ts
Normal file
1
packages/frontend/src/scripts/mfm-tags.ts
Normal file
@ -0,0 +1 @@
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];
|
41
packages/frontend/src/scripts/page-metadata.ts
Normal file
41
packages/frontend/src/scripts/page-metadata.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import * as misskey from 'misskey-js';
|
||||
import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue';
|
||||
|
||||
export const setPageMetadata = Symbol('setPageMetadata');
|
||||
export const pageMetadataProvider = Symbol('pageMetadataProvider');
|
||||
|
||||
export type PageMetadata = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string | null;
|
||||
avatar?: misskey.entities.User | null;
|
||||
userName?: misskey.entities.User | null;
|
||||
bg?: string;
|
||||
};
|
||||
|
||||
export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
|
||||
const _metadata = isRef(metadata) ? metadata : ref(metadata);
|
||||
|
||||
provide(pageMetadataProvider, _metadata);
|
||||
|
||||
const set = inject(setPageMetadata) as any;
|
||||
if (set) {
|
||||
set(_metadata);
|
||||
|
||||
onMounted(() => {
|
||||
set(_metadata);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
set(_metadata);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void {
|
||||
provide(setPageMetadata, callback);
|
||||
}
|
||||
|
||||
export function injectPageMetadata(): PageMetadata | undefined {
|
||||
return inject(pageMetadataProvider);
|
||||
}
|
152
packages/frontend/src/scripts/physics.ts
Normal file
152
packages/frontend/src/scripts/physics.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import * as Matter from 'matter-js';
|
||||
|
||||
export function physics(container: HTMLElement) {
|
||||
const containerWidth = container.offsetWidth;
|
||||
const containerHeight = container.offsetHeight;
|
||||
const containerCenterX = containerWidth / 2;
|
||||
|
||||
// サイズ固定化(要らないかも?)
|
||||
container.style.position = 'relative';
|
||||
container.style.boxSizing = 'border-box';
|
||||
container.style.width = `${containerWidth}px`;
|
||||
container.style.height = `${containerHeight}px`;
|
||||
|
||||
// create engine
|
||||
const engine = Matter.Engine.create({
|
||||
constraintIterations: 4,
|
||||
positionIterations: 8,
|
||||
velocityIterations: 8,
|
||||
});
|
||||
|
||||
const world = engine.world;
|
||||
|
||||
// create renderer
|
||||
const render = Matter.Render.create({
|
||||
engine: engine,
|
||||
//element: document.getElementById('debug'),
|
||||
options: {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
background: 'transparent', // transparent to hide
|
||||
wireframeBackground: 'transparent', // transparent to hide
|
||||
},
|
||||
});
|
||||
|
||||
// Disable to hide debug
|
||||
Matter.Render.run(render);
|
||||
|
||||
// create runner
|
||||
const runner = Matter.Runner.create();
|
||||
Matter.Runner.run(runner, engine);
|
||||
|
||||
const groundThickness = 1024;
|
||||
const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
|
||||
isStatic: true,
|
||||
restitution: 0.1,
|
||||
friction: 2,
|
||||
});
|
||||
|
||||
//const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
|
||||
//const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
|
||||
|
||||
Matter.World.add(world, [
|
||||
ground,
|
||||
//wallRight,
|
||||
//wallLeft,
|
||||
]);
|
||||
|
||||
const objEls = Array.from(container.children) as HTMLElement[];
|
||||
const objs: Matter.Body[] = [];
|
||||
for (const objEl of objEls) {
|
||||
const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
|
||||
const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
|
||||
|
||||
let obj: Matter.Body;
|
||||
if (objEl.classList.contains('_physics_circle_')) {
|
||||
obj = Matter.Bodies.circle(
|
||||
left + (objEl.offsetWidth / 2),
|
||||
top + (objEl.offsetHeight / 2),
|
||||
Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
|
||||
{
|
||||
restitution: 0.5,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const style = window.getComputedStyle(objEl);
|
||||
obj = Matter.Bodies.rectangle(
|
||||
left + (objEl.offsetWidth / 2),
|
||||
top + (objEl.offsetHeight / 2),
|
||||
objEl.offsetWidth,
|
||||
objEl.offsetHeight,
|
||||
{
|
||||
chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
|
||||
restitution: 0.5,
|
||||
},
|
||||
);
|
||||
}
|
||||
objEl.id = obj.id.toString();
|
||||
objs.push(obj);
|
||||
}
|
||||
|
||||
Matter.World.add(engine.world, objs);
|
||||
|
||||
// Add mouse control
|
||||
|
||||
const mouse = Matter.Mouse.create(container);
|
||||
const mouseConstraint = Matter.MouseConstraint.create(engine, {
|
||||
mouse: mouse,
|
||||
constraint: {
|
||||
stiffness: 0.1,
|
||||
render: {
|
||||
visible: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Matter.World.add(engine.world, mouseConstraint);
|
||||
|
||||
// keep the mouse in sync with rendering
|
||||
render.mouse = mouse;
|
||||
|
||||
for (const objEl of objEls) {
|
||||
objEl.style.position = 'absolute';
|
||||
objEl.style.top = '0';
|
||||
objEl.style.left = '0';
|
||||
objEl.style.margin = '0';
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(update);
|
||||
|
||||
let stop = false;
|
||||
|
||||
function update() {
|
||||
for (const objEl of objEls) {
|
||||
const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
|
||||
if (obj == null) continue;
|
||||
|
||||
const x = (obj.position.x - objEl.offsetWidth / 2);
|
||||
const y = (obj.position.y - objEl.offsetHeight / 2);
|
||||
const angle = obj.angle;
|
||||
objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
|
||||
}
|
||||
|
||||
if (!stop) {
|
||||
window.requestAnimationFrame(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 奈落に落ちたオブジェクトは消す
|
||||
const intervalId = window.setInterval(() => {
|
||||
for (const obj of objs) {
|
||||
if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
|
||||
}
|
||||
}, 1000 * 10);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stop = true;
|
||||
Matter.Runner.stop(runner);
|
||||
window.clearInterval(intervalId);
|
||||
},
|
||||
};
|
||||
}
|
21
packages/frontend/src/scripts/please-login.ts
Normal file
21
packages/frontend/src/scripts/please-login.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { popup } from '@/os';
|
||||
|
||||
export function pleaseLogin(path?: string) {
|
||||
if ($i) return;
|
||||
|
||||
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
|
||||
autoSet: true,
|
||||
message: i18n.ts.signinRequired,
|
||||
}, {
|
||||
cancelled: () => {
|
||||
if (path) {
|
||||
window.location.href = path;
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
|
||||
if (!path) throw new Error('signin required');
|
||||
}
|
23
packages/frontend/src/scripts/popout.ts
Normal file
23
packages/frontend/src/scripts/popout.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import * as config from '@/config';
|
||||
import { appendQuery } from './url';
|
||||
|
||||
export function popout(path: string, w?: HTMLElement) {
|
||||
let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
|
||||
url = appendQuery(url, 'zen');
|
||||
if (w) {
|
||||
const position = w.getBoundingClientRect();
|
||||
const width = parseInt(getComputedStyle(w, '').width, 10);
|
||||
const height = parseInt(getComputedStyle(w, '').height, 10);
|
||||
const x = window.screenX + position.left;
|
||||
const y = window.screenY + position.top;
|
||||
window.open(url, url,
|
||||
`width=${width}, height=${height}, top=${y}, left=${x}`);
|
||||
} else {
|
||||
const width = 400;
|
||||
const height = 500;
|
||||
const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
|
||||
const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
|
||||
window.open(url, url,
|
||||
`width=${width}, height=${height}, top=${x}, left=${y}`);
|
||||
}
|
||||
}
|
158
packages/frontend/src/scripts/popup-position.ts
Normal file
158
packages/frontend/src/scripts/popup-position.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { Ref } from 'vue';
|
||||
|
||||
export function calcPopupPosition(el: HTMLElement, props: {
|
||||
anchorElement: HTMLElement | null;
|
||||
innerMargin: number;
|
||||
direction: 'top' | 'bottom' | 'left' | 'right';
|
||||
align: 'top' | 'bottom' | 'left' | 'right' | 'center';
|
||||
alignOffset?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}): { top: number; left: number; transformOrigin: string; } {
|
||||
const contentWidth = el.offsetWidth;
|
||||
const contentHeight = el.offsetHeight;
|
||||
|
||||
let rect: DOMRect;
|
||||
|
||||
if (props.anchorElement) {
|
||||
rect = props.anchorElement.getBoundingClientRect();
|
||||
}
|
||||
|
||||
const calcPosWhenTop = () => {
|
||||
let left: number;
|
||||
let top: number;
|
||||
|
||||
if (props.anchorElement) {
|
||||
left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
|
||||
top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
|
||||
} else {
|
||||
left = props.x;
|
||||
top = (props.y - contentHeight) - props.innerMargin;
|
||||
}
|
||||
|
||||
left -= (el.offsetWidth / 2);
|
||||
|
||||
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
|
||||
}
|
||||
|
||||
return [left, top];
|
||||
};
|
||||
|
||||
const calcPosWhenBottom = () => {
|
||||
let left: number;
|
||||
let top: number;
|
||||
|
||||
if (props.anchorElement) {
|
||||
left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
|
||||
top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin;
|
||||
} else {
|
||||
left = props.x;
|
||||
top = (props.y) + props.innerMargin;
|
||||
}
|
||||
|
||||
left -= (el.offsetWidth / 2);
|
||||
|
||||
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
|
||||
}
|
||||
|
||||
return [left, top];
|
||||
};
|
||||
|
||||
const calcPosWhenLeft = () => {
|
||||
let left: number;
|
||||
let top: number;
|
||||
|
||||
if (props.anchorElement) {
|
||||
left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
|
||||
top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
|
||||
} else {
|
||||
left = (props.x - contentWidth) - props.innerMargin;
|
||||
top = props.y;
|
||||
}
|
||||
|
||||
top -= (el.offsetHeight / 2);
|
||||
|
||||
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - contentHeight + window.pageYOffset - 1;
|
||||
}
|
||||
|
||||
return [left, top];
|
||||
};
|
||||
|
||||
const calcPosWhenRight = () => {
|
||||
let left: number;
|
||||
let top: number;
|
||||
|
||||
if (props.anchorElement) {
|
||||
left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin;
|
||||
|
||||
if (props.align === 'top') {
|
||||
top = rect.top + window.pageYOffset;
|
||||
if (props.alignOffset != null) top += props.alignOffset;
|
||||
} else if (props.align === 'bottom') {
|
||||
// TODO
|
||||
} else { // center
|
||||
top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
|
||||
top -= (el.offsetHeight / 2);
|
||||
}
|
||||
} else {
|
||||
left = props.x + props.innerMargin;
|
||||
top = props.y;
|
||||
top -= (el.offsetHeight / 2);
|
||||
}
|
||||
|
||||
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - contentHeight + window.pageYOffset - 1;
|
||||
}
|
||||
|
||||
return [left, top];
|
||||
};
|
||||
|
||||
const calc = (): {
|
||||
left: number;
|
||||
top: number;
|
||||
transformOrigin: string;
|
||||
} => {
|
||||
switch (props.direction) {
|
||||
case 'top': {
|
||||
const [left, top] = calcPosWhenTop();
|
||||
|
||||
// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
|
||||
if (top - window.pageYOffset < 0) {
|
||||
const [left, top] = calcPosWhenBottom();
|
||||
return { left, top, transformOrigin: 'center top' };
|
||||
}
|
||||
|
||||
return { left, top, transformOrigin: 'center bottom' };
|
||||
}
|
||||
|
||||
case 'bottom': {
|
||||
const [left, top] = calcPosWhenBottom();
|
||||
// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
|
||||
return { left, top, transformOrigin: 'center top' };
|
||||
}
|
||||
|
||||
case 'left': {
|
||||
const [left, top] = calcPosWhenLeft();
|
||||
|
||||
// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
|
||||
if (left - window.pageXOffset < 0) {
|
||||
const [left, top] = calcPosWhenRight();
|
||||
return { left, top, transformOrigin: 'left center' };
|
||||
}
|
||||
|
||||
return { left, top, transformOrigin: 'right center' };
|
||||
}
|
||||
|
||||
case 'right': {
|
||||
const [left, top] = calcPosWhenRight();
|
||||
// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
|
||||
return { left, top, transformOrigin: 'left center' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return calc();
|
||||
}
|
41
packages/frontend/src/scripts/reaction-picker.ts
Normal file
41
packages/frontend/src/scripts/reaction-picker.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { defineAsyncComponent, Ref, ref } from 'vue';
|
||||
import { popup } from '@/os';
|
||||
|
||||
class ReactionPicker {
|
||||
private src: Ref<HTMLElement | null> = ref(null);
|
||||
private manualShowing = ref(false);
|
||||
private onChosen?: (reaction: string) => void;
|
||||
private onClosed?: () => void;
|
||||
|
||||
constructor() {
|
||||
// nop
|
||||
}
|
||||
|
||||
public async init() {
|
||||
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
|
||||
src: this.src,
|
||||
asReactionPicker: true,
|
||||
manualShowing: this.manualShowing,
|
||||
}, {
|
||||
done: reaction => {
|
||||
this.onChosen!(reaction);
|
||||
},
|
||||
close: () => {
|
||||
this.manualShowing.value = false;
|
||||
},
|
||||
closed: () => {
|
||||
this.src.value = null;
|
||||
this.onClosed!();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
|
||||
this.src.value = src;
|
||||
this.manualShowing.value = true;
|
||||
this.onChosen = onChosen;
|
||||
this.onClosed = onClosed;
|
||||
}
|
||||
}
|
||||
|
||||
export const reactionPicker = new ReactionPicker();
|
7
packages/frontend/src/scripts/safe-uri-decode.ts
Normal file
7
packages/frontend/src/scripts/safe-uri-decode.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function safeURIDecode(str: string): string {
|
||||
try {
|
||||
return decodeURIComponent(str);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
85
packages/frontend/src/scripts/scroll.ts
Normal file
85
packages/frontend/src/scripts/scroll.ts
Normal file
@ -0,0 +1,85 @@
|
||||
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
|
||||
|
||||
export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
||||
if (el == null || el.tagName === 'HTML') return null;
|
||||
const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y');
|
||||
if (overflow === 'scroll' || overflow === 'auto') {
|
||||
return el;
|
||||
} else {
|
||||
return getScrollContainer(el.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
export function getScrollPosition(el: Element | null): number {
|
||||
const container = getScrollContainer(el);
|
||||
return container == null ? window.scrollY : container.scrollTop;
|
||||
}
|
||||
|
||||
export function isTopVisible(el: Element | null): boolean {
|
||||
const scrollTop = getScrollPosition(el);
|
||||
const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
|
||||
|
||||
return scrollTop <= topPosition;
|
||||
}
|
||||
|
||||
export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
|
||||
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
|
||||
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
|
||||
}
|
||||
|
||||
export function onScrollTop(el: Element, cb) {
|
||||
const container = getScrollContainer(el) || window;
|
||||
const onScroll = ev => {
|
||||
if (!document.body.contains(el)) return;
|
||||
if (isTopVisible(el)) {
|
||||
cb();
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
|
||||
export function onScrollBottom(el: Element, cb) {
|
||||
const container = getScrollContainer(el) || window;
|
||||
const onScroll = ev => {
|
||||
if (!document.body.contains(el)) return;
|
||||
const pos = getScrollPosition(el);
|
||||
if (pos + el.clientHeight > el.scrollHeight - 1) {
|
||||
cb();
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
|
||||
export function scroll(el: Element, options: {
|
||||
top?: number;
|
||||
left?: number;
|
||||
behavior?: ScrollBehavior;
|
||||
}) {
|
||||
const container = getScrollContainer(el);
|
||||
if (container == null) {
|
||||
window.scroll(options);
|
||||
} else {
|
||||
container.scroll(options);
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
|
||||
scroll(el, { top: 0, ...options });
|
||||
}
|
||||
|
||||
export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
|
||||
scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する
|
||||
}
|
||||
|
||||
export function isBottom(el: Element, asobi = 0) {
|
||||
const container = getScrollContainer(el);
|
||||
const current = container
|
||||
? el.scrollTop + el.offsetHeight
|
||||
: window.scrollY + window.innerHeight;
|
||||
const max = container
|
||||
? el.scrollHeight
|
||||
: document.body.offsetHeight;
|
||||
return current >= (max - asobi);
|
||||
}
|
63
packages/frontend/src/scripts/search.ts
Normal file
63
packages/frontend/src/scripts/search.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { mainRouter } from '@/router';
|
||||
|
||||
export async function search() {
|
||||
const { canceled, result: query } = await os.inputText({
|
||||
title: i18n.ts.search,
|
||||
});
|
||||
if (canceled || query == null || query === '') return;
|
||||
|
||||
const q = query.trim();
|
||||
|
||||
if (q.startsWith('@') && !q.includes(' ')) {
|
||||
mainRouter.push(`/${q}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (q.startsWith('#')) {
|
||||
mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// like 2018/03/12
|
||||
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) {
|
||||
const date = new Date(q.replace(/-/g, '/'));
|
||||
|
||||
// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
|
||||
// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
|
||||
// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
|
||||
// 結果になってしまい、2018/03/12 のコンテンツは含まれない)
|
||||
if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
|
||||
date.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
// TODO
|
||||
//v.$root.$emit('warp', date);
|
||||
os.alert({
|
||||
icon: 'fas fa-history',
|
||||
iconOnly: true, autoClose: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (q.startsWith('https://')) {
|
||||
const promise = os.api('ap/show', {
|
||||
uri: q,
|
||||
});
|
||||
|
||||
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
|
||||
|
||||
const res = await promise;
|
||||
|
||||
if (res.type === 'User') {
|
||||
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
|
||||
} else if (res.type === 'Note') {
|
||||
mainRouter.push(`/notes/${res.object.id}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
|
||||
}
|
103
packages/frontend/src/scripts/select-file.ts
Normal file
103
packages/frontend/src/scripts/select-file.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { ref } from 'vue';
|
||||
import { DriveFile } from 'misskey-js/built/entities';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
import { uploadFile } from '@/scripts/upload';
|
||||
|
||||
function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
|
||||
return new Promise((res, rej) => {
|
||||
const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
|
||||
|
||||
const chooseFileFromPc = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = multiple;
|
||||
input.onchange = () => {
|
||||
const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
|
||||
|
||||
Promise.all(promises).then(driveFiles => {
|
||||
res(multiple ? driveFiles : driveFiles[0]);
|
||||
}).catch(err => {
|
||||
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
|
||||
});
|
||||
|
||||
// 一応廃棄
|
||||
(window as any).__misskey_input_ref__ = null;
|
||||
};
|
||||
|
||||
// https://qiita.com/fukasawah/items/b9dc732d95d99551013d
|
||||
// iOS Safari で正常に動かす為のおまじない
|
||||
(window as any).__misskey_input_ref__ = input;
|
||||
|
||||
input.click();
|
||||
};
|
||||
|
||||
const chooseFileFromDrive = () => {
|
||||
os.selectDriveFile(multiple).then(files => {
|
||||
res(files);
|
||||
});
|
||||
};
|
||||
|
||||
const chooseFileFromUrl = () => {
|
||||
os.inputText({
|
||||
title: i18n.ts.uploadFromUrl,
|
||||
type: 'url',
|
||||
placeholder: i18n.ts.uploadFromUrlDescription,
|
||||
}).then(({ canceled, result: url }) => {
|
||||
if (canceled) return;
|
||||
|
||||
const marker = Math.random().toString(); // TODO: UUIDとか使う
|
||||
|
||||
const connection = stream.useChannel('main');
|
||||
connection.on('urlUploadFinished', urlResponse => {
|
||||
if (urlResponse.marker === marker) {
|
||||
res(multiple ? [urlResponse.file] : urlResponse.file);
|
||||
connection.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
os.api('drive/files/upload-from-url', {
|
||||
url: url,
|
||||
folderId: defaultStore.state.uploadFolder,
|
||||
marker,
|
||||
});
|
||||
|
||||
os.alert({
|
||||
title: i18n.ts.uploadFromUrlRequested,
|
||||
text: i18n.ts.uploadFromUrlMayTakeTime,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
os.popupMenu([label ? {
|
||||
text: label,
|
||||
type: 'label',
|
||||
} : undefined, {
|
||||
type: 'switch',
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
}, {
|
||||
text: i18n.ts.upload,
|
||||
icon: 'ti ti-upload',
|
||||
action: chooseFileFromPc,
|
||||
}, {
|
||||
text: i18n.ts.fromDrive,
|
||||
icon: 'ti ti-cloud',
|
||||
action: chooseFileFromDrive,
|
||||
}, {
|
||||
text: i18n.ts.fromUrl,
|
||||
icon: 'ti ti-link',
|
||||
action: chooseFileFromUrl,
|
||||
}], src);
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFile(src: any, label: string | null = null): Promise<DriveFile> {
|
||||
return select(src, label, false) as Promise<DriveFile>;
|
||||
}
|
||||
|
||||
export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> {
|
||||
return select(src, label, true) as Promise<DriveFile[]>;
|
||||
}
|
10
packages/frontend/src/scripts/show-suspended-dialog.ts
Normal file
10
packages/frontend/src/scripts/show-suspended-dialog.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
export function showSuspendedDialog() {
|
||||
return os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.yourAccountSuspendedTitle,
|
||||
text: i18n.ts.yourAccountSuspendedDescription,
|
||||
});
|
||||
}
|
19
packages/frontend/src/scripts/shuffle.ts
Normal file
19
packages/frontend/src/scripts/shuffle.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 配列をシャッフル (破壊的)
|
||||
*/
|
||||
export function shuffle<T extends any[]>(array: T): T {
|
||||
let currentIndex = array.length, randomIndex;
|
||||
|
||||
// While there remain elements to shuffle.
|
||||
while (currentIndex !== 0) {
|
||||
// Pick a remaining element.
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
|
||||
// And swap it with the current element.
|
||||
[array[currentIndex], array[randomIndex]] = [
|
||||
array[randomIndex], array[currentIndex]];
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
66
packages/frontend/src/scripts/sound.ts
Normal file
66
packages/frontend/src/scripts/sound.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
|
||||
const cache = new Map<string, HTMLAudioElement>();
|
||||
|
||||
export const soundsTypes = [
|
||||
null,
|
||||
'syuilo/up',
|
||||
'syuilo/down',
|
||||
'syuilo/pope1',
|
||||
'syuilo/pope2',
|
||||
'syuilo/waon',
|
||||
'syuilo/popo',
|
||||
'syuilo/triple',
|
||||
'syuilo/poi1',
|
||||
'syuilo/poi2',
|
||||
'syuilo/pirori',
|
||||
'syuilo/pirori-wet',
|
||||
'syuilo/pirori-square-wet',
|
||||
'syuilo/square-pico',
|
||||
'syuilo/reverved',
|
||||
'syuilo/ryukyu',
|
||||
'syuilo/kick',
|
||||
'syuilo/snare',
|
||||
'syuilo/queue-jammed',
|
||||
'aisha/1',
|
||||
'aisha/2',
|
||||
'aisha/3',
|
||||
'noizenecio/kick_gaba1',
|
||||
'noizenecio/kick_gaba2',
|
||||
'noizenecio/kick_gaba3',
|
||||
'noizenecio/kick_gaba4',
|
||||
'noizenecio/kick_gaba5',
|
||||
'noizenecio/kick_gaba6',
|
||||
'noizenecio/kick_gaba7',
|
||||
] as const;
|
||||
|
||||
export function getAudio(file: string, useCache = true): HTMLAudioElement {
|
||||
let audio: HTMLAudioElement;
|
||||
if (useCache && cache.has(file)) {
|
||||
audio = cache.get(file);
|
||||
} else {
|
||||
audio = new Audio(`/client-assets/sounds/${file}.mp3`);
|
||||
if (useCache) cache.set(file, audio);
|
||||
}
|
||||
return audio;
|
||||
}
|
||||
|
||||
export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
|
||||
const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
|
||||
audio.volume = masterVolume - ((1 - volume) * masterVolume);
|
||||
return audio;
|
||||
}
|
||||
|
||||
export function play(type: string) {
|
||||
const sound = ColdDeviceStorage.get('sound_' + type as any);
|
||||
if (sound.type == null) return;
|
||||
playFile(sound.type, sound.volume);
|
||||
}
|
||||
|
||||
export function playFile(file: string, volume: number) {
|
||||
const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
|
||||
if (masterVolume === 0) return;
|
||||
|
||||
const audio = setVolume(getAudio(file), volume);
|
||||
audio.play();
|
||||
}
|
50
packages/frontend/src/scripts/sticky-sidebar.ts
Normal file
50
packages/frontend/src/scripts/sticky-sidebar.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export class StickySidebar {
|
||||
private lastScrollTop = 0;
|
||||
private container: HTMLElement;
|
||||
private el: HTMLElement;
|
||||
private spacer: HTMLElement;
|
||||
private marginTop: number;
|
||||
private isTop = false;
|
||||
private isBottom = false;
|
||||
private offsetTop: number;
|
||||
private globalHeaderHeight: number = 59;
|
||||
|
||||
constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
|
||||
this.container = container;
|
||||
this.el = this.container.children[0] as HTMLElement;
|
||||
this.el.style.position = 'sticky';
|
||||
this.spacer = document.createElement('div');
|
||||
this.container.prepend(this.spacer);
|
||||
this.marginTop = marginTop;
|
||||
this.offsetTop = this.container.getBoundingClientRect().top;
|
||||
this.globalHeaderHeight = globalHeaderHeight;
|
||||
}
|
||||
|
||||
public calc(scrollTop: number) {
|
||||
if (scrollTop > this.lastScrollTop) { // downscroll
|
||||
const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
|
||||
this.el.style.bottom = null;
|
||||
this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
|
||||
|
||||
this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
|
||||
|
||||
if (this.isTop) {
|
||||
this.isTop = false;
|
||||
this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
|
||||
}
|
||||
} else { // upscroll
|
||||
const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
|
||||
this.el.style.top = null;
|
||||
this.el.style.bottom = `${-overflow}px`;
|
||||
|
||||
this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
|
||||
|
||||
if (this.isBottom) {
|
||||
this.isBottom = false;
|
||||
this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
|
||||
}
|
||||
}
|
81
packages/frontend/src/scripts/theme-editor.ts
Normal file
81
packages/frontend/src/scripts/theme-editor.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { themeProps, Theme } from './theme';
|
||||
|
||||
export type Default = null;
|
||||
export type Color = string;
|
||||
export type FuncName = 'alpha' | 'darken' | 'lighten';
|
||||
export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
|
||||
export type RefProp = { type: 'refProp'; key: string; };
|
||||
export type RefConst = { type: 'refConst'; key: string; };
|
||||
export type Css = { type: 'css'; value: string; };
|
||||
|
||||
export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
|
||||
|
||||
export type ThemeViewModel = [ string, ThemeValue ][];
|
||||
|
||||
export const fromThemeString = (str?: string) : ThemeValue => {
|
||||
if (!str) return null;
|
||||
if (str.startsWith(':')) {
|
||||
const parts = str.slice(1).split('<');
|
||||
const name = parts[0] as FuncName;
|
||||
const arg = parseFloat(parts[1]);
|
||||
const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
|
||||
return { type: 'func', name, arg, value };
|
||||
} else if (str.startsWith('@')) {
|
||||
return {
|
||||
type: 'refProp',
|
||||
key: str.slice(1),
|
||||
};
|
||||
} else if (str.startsWith('$')) {
|
||||
return {
|
||||
type: 'refConst',
|
||||
key: str.slice(1),
|
||||
};
|
||||
} else if (str.startsWith('"')) {
|
||||
return {
|
||||
type: 'css',
|
||||
value: str.substr(1).trim(),
|
||||
};
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
|
||||
if (typeof value === 'string') return value;
|
||||
switch (value.type) {
|
||||
case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
|
||||
case 'refProp': return `@${value.key}`;
|
||||
case 'refConst': return `$${value.key}`;
|
||||
case 'css': return `" ${value.value}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
|
||||
const props = { } as { [key: string]: string };
|
||||
for (const [key, value] of vm) {
|
||||
if (value === null) continue;
|
||||
props[key] = toThemeString(value);
|
||||
}
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
name, desc, author, props, base,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertToViewModel = (theme: Theme): ThemeViewModel => {
|
||||
const vm: ThemeViewModel = [];
|
||||
// プロパティの登録
|
||||
vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
|
||||
|
||||
// 定数の登録
|
||||
const consts = Object
|
||||
.keys(theme.props)
|
||||
.filter(k => k.startsWith('$'))
|
||||
.map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]);
|
||||
|
||||
vm.push(...consts);
|
||||
return vm;
|
||||
};
|
148
packages/frontend/src/scripts/theme.ts
Normal file
148
packages/frontend/src/scripts/theme.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { ref } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { globalEvents } from '@/events';
|
||||
|
||||
export type Theme = {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
desc?: string;
|
||||
base?: 'dark' | 'light';
|
||||
props: Record<string, string>;
|
||||
};
|
||||
|
||||
import lightTheme from '@/themes/_light.json5';
|
||||
import darkTheme from '@/themes/_dark.json5';
|
||||
import { deepClone } from './clone';
|
||||
|
||||
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
|
||||
|
||||
export const getBuiltinThemes = () => Promise.all(
|
||||
[
|
||||
'l-light',
|
||||
'l-coffee',
|
||||
'l-apricot',
|
||||
'l-rainy',
|
||||
'l-vivid',
|
||||
'l-cherry',
|
||||
'l-sushi',
|
||||
'l-u0',
|
||||
|
||||
'd-dark',
|
||||
'd-persimmon',
|
||||
'd-astro',
|
||||
'd-future',
|
||||
'd-botanical',
|
||||
'd-green-lime',
|
||||
'd-green-orange',
|
||||
'd-cherry',
|
||||
'd-ice',
|
||||
'd-u0',
|
||||
].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
|
||||
);
|
||||
|
||||
export const getBuiltinThemesRef = () => {
|
||||
const builtinThemes = ref<Theme[]>([]);
|
||||
getBuiltinThemes().then(themes => builtinThemes.value = themes);
|
||||
return builtinThemes;
|
||||
};
|
||||
|
||||
let timeout = null;
|
||||
|
||||
export function applyTheme(theme: Theme, persist = true) {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
|
||||
document.documentElement.classList.add('_themeChanging_');
|
||||
|
||||
timeout = window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('_themeChanging_');
|
||||
}, 1000);
|
||||
|
||||
const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
|
||||
|
||||
// Deep copy
|
||||
const _theme = deepClone(theme);
|
||||
|
||||
if (_theme.base) {
|
||||
const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
|
||||
if (base) _theme.props = Object.assign({}, base.props, _theme.props);
|
||||
}
|
||||
|
||||
const props = compile(_theme);
|
||||
|
||||
for (const tag of document.head.children) {
|
||||
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
|
||||
tag.setAttribute('content', props['htmlThemeColor']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
document.documentElement.style.setProperty(`--${k}`, v.toString());
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('color-schema', colorSchema);
|
||||
|
||||
if (persist) {
|
||||
localStorage.setItem('theme', JSON.stringify(props));
|
||||
localStorage.setItem('colorSchema', colorSchema);
|
||||
}
|
||||
|
||||
// 色計算など再度行えるようにクライアント全体に通知
|
||||
globalEvents.emit('themeChanged');
|
||||
}
|
||||
|
||||
function compile(theme: Theme): Record<string, string> {
|
||||
function getColor(val: string): tinycolor.Instance {
|
||||
// ref (prop)
|
||||
if (val[0] === '@') {
|
||||
return getColor(theme.props[val.substr(1)]);
|
||||
}
|
||||
|
||||
// ref (const)
|
||||
else if (val[0] === '$') {
|
||||
return getColor(theme.props[val]);
|
||||
}
|
||||
|
||||
// func
|
||||
else if (val[0] === ':') {
|
||||
const parts = val.split('<');
|
||||
const func = parts.shift().substr(1);
|
||||
const arg = parseFloat(parts.shift());
|
||||
const color = getColor(parts.join('<'));
|
||||
|
||||
switch (func) {
|
||||
case 'darken': return color.darken(arg);
|
||||
case 'lighten': return color.lighten(arg);
|
||||
case 'alpha': return color.setAlpha(arg);
|
||||
case 'hue': return color.spin(arg);
|
||||
case 'saturate': return color.saturate(arg);
|
||||
}
|
||||
}
|
||||
|
||||
// other case
|
||||
return tinycolor(val);
|
||||
}
|
||||
|
||||
const props = {};
|
||||
|
||||
for (const [k, v] of Object.entries(theme.props)) {
|
||||
if (k.startsWith('$')) continue; // ignore const
|
||||
|
||||
props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
function genValue(c: tinycolor.Instance): string {
|
||||
return c.toRgbString();
|
||||
}
|
||||
|
||||
export function validateTheme(theme: Record<string, any>): boolean {
|
||||
if (theme.id == null || typeof theme.id !== 'string') return false;
|
||||
if (theme.name == null || typeof theme.name !== 'string') return false;
|
||||
if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
|
||||
if (theme.props == null || typeof theme.props !== 'object') return false;
|
||||
return true;
|
||||
}
|
39
packages/frontend/src/scripts/time.ts
Normal file
39
packages/frontend/src/scripts/time.ts
Normal file
@ -0,0 +1,39 @@
|
||||
const dateTimeIntervals = {
|
||||
'day': 86400000,
|
||||
'hour': 3600000,
|
||||
'ms': 1,
|
||||
};
|
||||
|
||||
export function dateUTC(time: number[]): Date {
|
||||
const d = time.length === 2 ? Date.UTC(time[0], time[1])
|
||||
: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
|
||||
: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
|
||||
: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
|
||||
: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
|
||||
: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
|
||||
: null;
|
||||
|
||||
if (!d) throw 'wrong number of arguments';
|
||||
|
||||
return new Date(d);
|
||||
}
|
||||
|
||||
export function isTimeSame(a: Date, b: Date): boolean {
|
||||
return a.getTime() === b.getTime();
|
||||
}
|
||||
|
||||
export function isTimeBefore(a: Date, b: Date): boolean {
|
||||
return (a.getTime() - b.getTime()) < 0;
|
||||
}
|
||||
|
||||
export function isTimeAfter(a: Date, b: Date): boolean {
|
||||
return (a.getTime() - b.getTime()) > 0;
|
||||
}
|
||||
|
||||
export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
|
||||
return new Date(x.getTime() + (value * dateTimeIntervals[span]));
|
||||
}
|
||||
|
||||
export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
|
||||
return new Date(x.getTime() - (value * dateTimeIntervals[span]));
|
||||
}
|
49
packages/frontend/src/scripts/timezones.ts
Normal file
49
packages/frontend/src/scripts/timezones.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export const timezones = [{
|
||||
name: 'UTC',
|
||||
abbrev: 'UTC',
|
||||
offset: 0,
|
||||
}, {
|
||||
name: 'Europe/Berlin',
|
||||
abbrev: 'CET',
|
||||
offset: 60,
|
||||
}, {
|
||||
name: 'Asia/Tokyo',
|
||||
abbrev: 'JST',
|
||||
offset: 540,
|
||||
}, {
|
||||
name: 'Asia/Seoul',
|
||||
abbrev: 'KST',
|
||||
offset: 540,
|
||||
}, {
|
||||
name: 'Asia/Shanghai',
|
||||
abbrev: 'CST',
|
||||
offset: 480,
|
||||
}, {
|
||||
name: 'Australia/Sydney',
|
||||
abbrev: 'AEST',
|
||||
offset: 600,
|
||||
}, {
|
||||
name: 'Australia/Darwin',
|
||||
abbrev: 'ACST',
|
||||
offset: 570,
|
||||
}, {
|
||||
name: 'Australia/Perth',
|
||||
abbrev: 'AWST',
|
||||
offset: 480,
|
||||
}, {
|
||||
name: 'America/New_York',
|
||||
abbrev: 'EST',
|
||||
offset: -300,
|
||||
}, {
|
||||
name: 'America/Mexico_City',
|
||||
abbrev: 'CST',
|
||||
offset: -360,
|
||||
}, {
|
||||
name: 'America/Phoenix',
|
||||
abbrev: 'MST',
|
||||
offset: -420,
|
||||
}, {
|
||||
name: 'America/Los_Angeles',
|
||||
abbrev: 'PST',
|
||||
offset: -480,
|
||||
}];
|
23
packages/frontend/src/scripts/touch.ts
Normal file
23
packages/frontend/src/scripts/touch.ts
Normal file
@ -0,0 +1,23 @@
|
||||
const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
|
||||
|
||||
export let isTouchUsing = false;
|
||||
|
||||
export let isScreenTouching = false;
|
||||
|
||||
if (isTouchSupported) {
|
||||
window.addEventListener('touchstart', () => {
|
||||
// maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
|
||||
// タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
|
||||
isTouchUsing = true;
|
||||
|
||||
isScreenTouching = true;
|
||||
}, { passive: true });
|
||||
|
||||
window.addEventListener('touchend', () => {
|
||||
// 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、
|
||||
// touchendイベントでもtouchstartイベントと同様にtrueにする
|
||||
isTouchUsing = true;
|
||||
|
||||
isScreenTouching = false;
|
||||
}, { passive: true });
|
||||
}
|
15
packages/frontend/src/scripts/unison-reload.ts
Normal file
15
packages/frontend/src/scripts/unison-reload.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// SafariがBroadcastChannel未実装なのでライブラリを使う
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
|
||||
export const reloadChannel = new BroadcastChannel<string | null>('reload');
|
||||
|
||||
// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
|
||||
export function unisonReload(path?: string) {
|
||||
if (path !== undefined) {
|
||||
reloadChannel.postMessage(path);
|
||||
location.href = path;
|
||||
} else {
|
||||
reloadChannel.postMessage(null);
|
||||
location.reload();
|
||||
}
|
||||
}
|
137
packages/frontend/src/scripts/upload.ts
Normal file
137
packages/frontend/src/scripts/upload.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { readAndCompressImage } from 'browser-image-resizer';
|
||||
import { getCompressionConfig } from './upload/compress-config';
|
||||
import { defaultStore } from '@/store';
|
||||
import { apiUrl } from '@/config';
|
||||
import { $i } from '@/account';
|
||||
import { alert } from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type Uploading = {
|
||||
id: string;
|
||||
name: string;
|
||||
progressMax: number | undefined;
|
||||
progressValue: number | undefined;
|
||||
img: string;
|
||||
};
|
||||
export const uploads = ref<Uploading[]>([]);
|
||||
|
||||
const mimeTypeMap = {
|
||||
'image/webp': 'webp',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
} as const;
|
||||
|
||||
export function uploadFile(
|
||||
file: File,
|
||||
folder?: any,
|
||||
name?: string,
|
||||
keepOriginal: boolean = defaultStore.state.keepOriginalUploading,
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
if ($i == null) throw new Error('Not logged in');
|
||||
|
||||
if (folder && typeof folder === 'object') folder = folder.id;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = Math.random().toString();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (): Promise<void> => {
|
||||
const ctx = reactive<Uploading>({
|
||||
id: id,
|
||||
name: name ?? file.name ?? 'untitled',
|
||||
progressMax: undefined,
|
||||
progressValue: undefined,
|
||||
img: window.URL.createObjectURL(file),
|
||||
});
|
||||
|
||||
uploads.value.push(ctx);
|
||||
|
||||
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
|
||||
let resizedImage: Blob | undefined;
|
||||
if (config) {
|
||||
try {
|
||||
const resized = await readAndCompressImage(file, config);
|
||||
if (resized.size < file.size || file.type === 'image/webp') {
|
||||
// The compression may not always reduce the file size
|
||||
// (and WebP is not browser safe yet)
|
||||
resizedImage = resized;
|
||||
}
|
||||
if (_DEV_) {
|
||||
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
|
||||
console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
|
||||
}
|
||||
|
||||
ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
|
||||
} catch (err) {
|
||||
console.error('Failed to resize image', err);
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('i', $i.token);
|
||||
formData.append('force', 'true');
|
||||
formData.append('file', resizedImage ?? file);
|
||||
formData.append('name', ctx.name);
|
||||
if (folder) formData.append('folderId', folder);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
||||
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
|
||||
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
|
||||
// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
|
||||
uploads.value = uploads.value.filter(x => x.id !== id);
|
||||
|
||||
if (ev.target?.response) {
|
||||
const res = JSON.parse(ev.target.response);
|
||||
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
|
||||
alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToUpload,
|
||||
text: i18n.ts.cannotUploadBecauseInappropriate,
|
||||
});
|
||||
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
|
||||
alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToUpload,
|
||||
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToUpload,
|
||||
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
alert({
|
||||
type: 'error',
|
||||
title: 'Failed to upload',
|
||||
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
|
||||
});
|
||||
}
|
||||
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const driveFile = JSON.parse(ev.target.response);
|
||||
|
||||
resolve(driveFile);
|
||||
|
||||
uploads.value = uploads.value.filter(x => x.id !== id);
|
||||
}) as (ev: ProgressEvent<EventTarget>) => any;
|
||||
|
||||
xhr.upload.onprogress = ev => {
|
||||
if (ev.lengthComputable) {
|
||||
ctx.progressMax = ev.total;
|
||||
ctx.progressValue = ev.loaded;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
23
packages/frontend/src/scripts/upload/compress-config.ts
Normal file
23
packages/frontend/src/scripts/upload/compress-config.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import isAnimated from 'is-file-animated';
|
||||
import type { BrowserImageResizerConfig } from 'browser-image-resizer';
|
||||
|
||||
const compressTypeMap = {
|
||||
'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
|
||||
'image/png': { quality: 1, mimeType: 'image/png' },
|
||||
'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
|
||||
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
|
||||
} as const;
|
||||
|
||||
export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> {
|
||||
const imgConfig = compressTypeMap[file.type];
|
||||
if (!imgConfig || await isAnimated(file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
debug: true,
|
||||
...imgConfig,
|
||||
};
|
||||
}
|
13
packages/frontend/src/scripts/url.ts
Normal file
13
packages/frontend/src/scripts/url.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function query(obj: Record<string, any>): string {
|
||||
const params = Object.entries(obj)
|
||||
.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
|
||||
.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
|
||||
|
||||
return Object.entries(params)
|
||||
.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function appendQuery(url: string, query: string): string {
|
||||
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
|
||||
}
|
54
packages/frontend/src/scripts/use-chart-tooltip.ts
Normal file
54
packages/frontend/src/scripts/use-chart-tooltip.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import MkChartTooltip from '@/components/MkChartTooltip.vue';
|
||||
|
||||
export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
|
||||
const tooltipShowing = ref(false);
|
||||
const tooltipX = ref(0);
|
||||
const tooltipY = ref(0);
|
||||
const tooltipTitle = ref(null);
|
||||
const tooltipSeries = ref(null);
|
||||
let disposeTooltipComponent;
|
||||
|
||||
os.popup(MkChartTooltip, {
|
||||
showing: tooltipShowing,
|
||||
x: tooltipX,
|
||||
y: tooltipY,
|
||||
title: tooltipTitle,
|
||||
series: tooltipSeries,
|
||||
}, {}).then(({ dispose }) => {
|
||||
disposeTooltipComponent = dispose;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (disposeTooltipComponent) disposeTooltipComponent();
|
||||
});
|
||||
|
||||
function handler(context) {
|
||||
if (context.tooltip.opacity === 0) {
|
||||
tooltipShowing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
tooltipTitle.value = context.tooltip.title[0];
|
||||
tooltipSeries.value = context.tooltip.body.map((b, i) => ({
|
||||
backgroundColor: context.tooltip.labelColors[i].backgroundColor,
|
||||
borderColor: context.tooltip.labelColors[i].borderColor,
|
||||
text: b.lines[0],
|
||||
}));
|
||||
|
||||
const rect = context.chart.canvas.getBoundingClientRect();
|
||||
|
||||
tooltipShowing.value = true;
|
||||
tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
|
||||
if (opts.position === 'top') {
|
||||
tooltipY.value = rect.top + window.pageYOffset;
|
||||
} else if (opts.position === 'middle') {
|
||||
tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handler,
|
||||
};
|
||||
}
|
24
packages/frontend/src/scripts/use-interval.ts
Normal file
24
packages/frontend/src/scripts/use-interval.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export function useInterval(fn: () => void, interval: number, options: {
|
||||
immediate: boolean;
|
||||
afterMounted: boolean;
|
||||
}): void {
|
||||
if (Number.isNaN(interval)) return;
|
||||
|
||||
let intervalId: number | null = null;
|
||||
|
||||
if (options.afterMounted) {
|
||||
onMounted(() => {
|
||||
if (options.immediate) fn();
|
||||
intervalId = window.setInterval(fn, interval);
|
||||
});
|
||||
} else {
|
||||
if (options.immediate) fn();
|
||||
intervalId = window.setInterval(fn, interval);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
});
|
||||
}
|
47
packages/frontend/src/scripts/use-leave-guard.ts
Normal file
47
packages/frontend/src/scripts/use-leave-guard.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { inject, onUnmounted, Ref } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
|
||||
export function useLeaveGuard(enabled: Ref<boolean>) {
|
||||
/* TODO
|
||||
const setLeaveGuard = inject('setLeaveGuard');
|
||||
|
||||
if (setLeaveGuard) {
|
||||
setLeaveGuard(async () => {
|
||||
if (!enabled.value) return false;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.leaveConfirm,
|
||||
});
|
||||
|
||||
return canceled;
|
||||
});
|
||||
} else {
|
||||
onBeforeRouteLeave(async (to, from) => {
|
||||
if (!enabled.value) return true;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.leaveConfirm,
|
||||
});
|
||||
|
||||
return !canceled;
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
function onBeforeLeave(ev: BeforeUnloadEvent) {
|
||||
if (enabled.value) {
|
||||
ev.preventDefault();
|
||||
ev.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', onBeforeLeave);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', onBeforeLeave);
|
||||
});
|
||||
*/
|
||||
}
|
110
packages/frontend/src/scripts/use-note-capture.ts
Normal file
110
packages/frontend/src/scripts/use-note-capture.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { onUnmounted, Ref } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { stream } from '@/stream';
|
||||
import { $i } from '@/account';
|
||||
|
||||
export function useNoteCapture(props: {
|
||||
rootEl: Ref<HTMLElement>;
|
||||
note: Ref<misskey.entities.Note>;
|
||||
isDeletedRef: Ref<boolean>;
|
||||
}) {
|
||||
const note = props.note;
|
||||
const connection = $i ? stream : null;
|
||||
|
||||
function onStreamNoteUpdated(noteData): void {
|
||||
const { type, id, body } = noteData;
|
||||
|
||||
if (id !== note.value.id) return;
|
||||
|
||||
switch (type) {
|
||||
case 'reacted': {
|
||||
const reaction = body.reaction;
|
||||
|
||||
if (body.emoji) {
|
||||
const emojis = note.value.emojis || [];
|
||||
if (!emojis.includes(body.emoji)) {
|
||||
note.value.emojis = [...emojis, body.emoji];
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
||||
const currentCount = (note.value.reactions || {})[reaction] || 0;
|
||||
|
||||
note.value.reactions[reaction] = currentCount + 1;
|
||||
|
||||
if ($i && (body.userId === $i.id)) {
|
||||
note.value.myReaction = reaction;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unreacted': {
|
||||
const reaction = body.reaction;
|
||||
|
||||
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
||||
const currentCount = (note.value.reactions || {})[reaction] || 0;
|
||||
|
||||
note.value.reactions[reaction] = Math.max(0, currentCount - 1);
|
||||
|
||||
if ($i && (body.userId === $i.id)) {
|
||||
note.value.myReaction = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pollVoted': {
|
||||
const choice = body.choice;
|
||||
|
||||
const choices = [...note.value.poll.choices];
|
||||
choices[choice] = {
|
||||
...choices[choice],
|
||||
votes: choices[choice].votes + 1,
|
||||
...($i && (body.userId === $i.id) ? {
|
||||
isVoted: true,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
note.value.poll.choices = choices;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'deleted': {
|
||||
props.isDeletedRef.value = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function capture(withHandler = false): void {
|
||||
if (connection) {
|
||||
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
|
||||
connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id });
|
||||
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
function decapture(withHandler = false): void {
|
||||
if (connection) {
|
||||
connection.send('un', {
|
||||
id: note.value.id,
|
||||
});
|
||||
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamConnected() {
|
||||
capture(false);
|
||||
}
|
||||
|
||||
capture(true);
|
||||
if (connection) {
|
||||
connection.on('_connected_', onStreamConnected);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
decapture(true);
|
||||
if (connection) {
|
||||
connection.off('_connected_', onStreamConnected);
|
||||
}
|
||||
});
|
||||
}
|
86
packages/frontend/src/scripts/use-tooltip.ts
Normal file
86
packages/frontend/src/scripts/use-tooltip.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { Ref, ref, watch, onUnmounted } from 'vue';
|
||||
|
||||
export function useTooltip(
|
||||
elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
|
||||
onShow: (showing: Ref<boolean>) => void,
|
||||
delay = 300,
|
||||
): void {
|
||||
let isHovering = false;
|
||||
|
||||
// iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
|
||||
// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
|
||||
// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...?
|
||||
let shouldIgnoreMouseover = false;
|
||||
|
||||
let timeoutId: number;
|
||||
|
||||
let changeShowingState: (() => void) | null;
|
||||
|
||||
const open = () => {
|
||||
close();
|
||||
if (!isHovering) return;
|
||||
if (elRef.value == null) return;
|
||||
const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
|
||||
if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
|
||||
|
||||
const showing = ref(true);
|
||||
onShow(showing);
|
||||
changeShowingState = () => {
|
||||
showing.value = false;
|
||||
};
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (changeShowingState != null) {
|
||||
changeShowingState();
|
||||
changeShowingState = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseover = () => {
|
||||
if (isHovering) return;
|
||||
if (shouldIgnoreMouseover) return;
|
||||
isHovering = true;
|
||||
timeoutId = window.setTimeout(open, delay);
|
||||
};
|
||||
|
||||
const onMouseleave = () => {
|
||||
if (!isHovering) return;
|
||||
isHovering = false;
|
||||
window.clearTimeout(timeoutId);
|
||||
close();
|
||||
};
|
||||
|
||||
const onTouchstart = () => {
|
||||
shouldIgnoreMouseover = true;
|
||||
if (isHovering) return;
|
||||
isHovering = true;
|
||||
timeoutId = window.setTimeout(open, delay);
|
||||
};
|
||||
|
||||
const onTouchend = () => {
|
||||
if (!isHovering) return;
|
||||
isHovering = false;
|
||||
window.clearTimeout(timeoutId);
|
||||
close();
|
||||
};
|
||||
|
||||
const stop = watch(elRef, () => {
|
||||
if (elRef.value) {
|
||||
stop();
|
||||
const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
|
||||
el.addEventListener('mouseover', onMouseover, { passive: true });
|
||||
el.addEventListener('mouseleave', onMouseleave, { passive: true });
|
||||
el.addEventListener('touchstart', onTouchstart, { passive: true });
|
||||
el.addEventListener('touchend', onTouchend, { passive: true });
|
||||
el.addEventListener('click', close, { passive: true });
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
flush: 'post',
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
close();
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user