refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View File

@ -0,0 +1,33 @@
export function byteify(data: string, encoding: 'ascii' | 'base64' | 'hex') {
switch (encoding) {
case 'ascii':
return Uint8Array.from(data, c => c.charCodeAt(0));
case 'base64':
return Uint8Array.from(
atob(
data
.replace(/-/g, '+')
.replace(/_/g, '/')
),
c => c.charCodeAt(0)
);
case 'hex':
return new Uint8Array(
data
.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));
}

View File

@ -0,0 +1,44 @@
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.dialog({
type: type ? type.value : 'info',
title: title.value,
text: text.value,
});
}),
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
const confirm = await os.dialog({
type: type ? type.value : 'question',
showCancelButton: true,
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)));
}),
};
}

View File

@ -0,0 +1,138 @@
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 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 (!obj.hasOwnProperty(key)) {
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 = [];
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;
}

View File

@ -0,0 +1,276 @@
import { Ref, ref } from 'vue';
import * as 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: Function;
} | null;
private textarea: any;
private vm: any;
private currentType: string;
private opts: {
model: string;
};
private opening: boolean;
private get text(): string {
return this.vm[this.opts.model];
}
private set text(text: string) {
this.vm[this.opts.model] = text;
}
/**
* 対象のテキストエリアを与えてインスタンスを初期化します。
*/
constructor(textarea, vm, opts) {
//#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.vm = vm;
this.opts = opts;
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(import('@/components/autocomplete.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}`;
// キャレットを戻す
this.vm.$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}`;
// キャレットを戻す
this.vm.$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;
// キャレットを戻す
this.vm.$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}`;
// キャレットを戻す
this.vm.$nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + (value.length + 3);
this.textarea.setSelectionRange(pos, pos);
});
}
}
}

View File

@ -0,0 +1,26 @@
export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
// 自分自身
if (me && (note.userId === me.id)) return false;
const words = mutedWords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (words.length > 0) {
if (note.text == null) return false;
const matched = words.some(and =>
and.every(keyword => {
const regexp = keyword.match(/^\/(.+)\/(.*)$/);
if (regexp) {
return new RegExp(regexp[1], regexp[2]).test(note.text!);
}
return note.text!.includes(keyword);
}));
if (matched) return true;
}
return false;
}

View File

@ -0,0 +1,48 @@
export function collectPageVars(content) {
const pageVars = [];
const collect = (xs: any[]) => {
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;
}

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

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

View File

@ -0,0 +1,7 @@
// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
export const emojilist = require('../emojilist.json') as {
name: string;
keywords: string[];
char: string;
category: 'people' | 'animals_and_nature' | 'food_and_drink' | 'activity' | 'travel_and_places' | 'objects' | 'symbols' | 'flags';
}[];

View File

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

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

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

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

View File

@ -0,0 +1,31 @@
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: 'array';
default: unknown[] | null;
hidden?: boolean;
};
export type Form = Record<string, FormItem>;

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

View File

@ -0,0 +1,263 @@
import { count, concat } from '@/scripts/array';
// MISSKEY REVERSI ENGINE
/**
* true ... 黒
* false ... 白
*/
export type Color = boolean;
const BLACK = true;
const WHITE = false;
export type MapPixel = 'null' | 'empty';
export type Options = {
isLlotheo: boolean;
canPutEverywhere: boolean;
loopedBoard: boolean;
};
export type Undo = {
/**
* 色
*/
color: Color;
/**
* どこに打ったか
*/
pos: number;
/**
* 反転した石の位置の配列
*/
effects: number[];
/**
* ターン
*/
turn: Color | null;
};
/**
* リバーシエンジン
*/
export default class Reversi {
public map: MapPixel[];
public mapWidth: number;
public mapHeight: number;
public board: (Color | null | undefined)[];
public turn: Color | null = BLACK;
public opts: Options;
public prevPos = -1;
public prevColor: Color | null = null;
private logs: Undo[] = [];
/**
* ゲームを初期化します
*/
constructor(map: string[], opts: Options) {
//#region binds
this.put = this.put.bind(this);
//#endregion
//#region Options
this.opts = opts;
if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
//#endregion
//#region Parse map data
this.mapWidth = map[0].length;
this.mapHeight = map.length;
const mapData = map.join('');
this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
//#endregion
// ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
if (!this.canPutSomewhere(BLACK))
this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
}
/**
* 黒石の数
*/
public get blackCount() {
return count(BLACK, this.board);
}
/**
* 白石の数
*/
public get whiteCount() {
return count(WHITE, this.board);
}
public transformPosToXy(pos: number): number[] {
const x = pos % this.mapWidth;
const y = Math.floor(pos / this.mapWidth);
return [x, y];
}
public transformXyToPos(x: number, y: number): number {
return x + (y * this.mapWidth);
}
/**
* 指定のマスに石を打ちます
* @param color 石の色
* @param pos 位置
*/
public put(color: Color, pos: number) {
this.prevPos = pos;
this.prevColor = color;
this.board[pos] = color;
// 反転させられる石を取得
const effects = this.effects(color, pos);
// 反転させる
for (const pos of effects) {
this.board[pos] = color;
}
const turn = this.turn;
this.logs.push({
color,
pos,
effects,
turn
});
this.calcTurn();
}
private calcTurn() {
// ターン計算
this.turn =
this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
this.canPutSomewhere(this.prevColor!) ? this.prevColor :
null;
}
public undo() {
const undo = this.logs.pop()!;
this.prevColor = undo.color;
this.prevPos = undo.pos;
this.board[undo.pos] = null;
for (const pos of undo.effects) {
const color = this.board[pos];
this.board[pos] = !color;
}
this.turn = undo.turn;
}
/**
* 指定した位置のマップデータのマスを取得します
* @param pos 位置
*/
public mapDataGet(pos: number): MapPixel {
const [x, y] = this.transformPosToXy(pos);
return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
}
/**
* 打つことができる場所を取得します
*/
public puttablePlaces(color: Color): number[] {
return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
}
/**
* 打つことができる場所があるかどうかを取得します
*/
public canPutSomewhere(color: Color): boolean {
return this.puttablePlaces(color).length > 0;
}
/**
* 指定のマスに石を打つことができるかどうかを取得します
* @param color 自分の色
* @param pos 位置
*/
public canPut(color: Color, pos: number): boolean {
return (
this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
}
/**
* 指定のマスに石を置いた時の、反転させられる石を取得します
* @param color 自分の色
* @param initPos 位置
*/
public effects(color: Color, initPos: number): number[] {
const enemyColor = !color;
const diffVectors: [number, number][] = [
[ 0, -1], // 上
[ +1, -1], // 右上
[ +1, 0], // 右
[ +1, +1], // 右下
[ 0, +1], // 下
[ -1, +1], // 左下
[ -1, 0], // 左
[ -1, -1] // 左上
];
const effectsInLine = ([dx, dy]: [number, number]): number[] => {
const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
let [x, y] = this.transformPosToXy(initPos);
while (true) {
[x, y] = nextPos(x, y);
// 座標が指し示す位置がボード外に出たとき
if (this.opts.loopedBoard && this.transformXyToPos(
(x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
(y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos)
// 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
return found;
else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight)
return []; // 挟めないことが確定 (盤面外に到達)
const pos = this.transformXyToPos(x, y);
if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
const stone = this.board[pos];
if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
}
};
return concat(diffVectors.map(effectsInLine));
}
/**
* ゲームが終了したか否か
*/
public get isEnded(): boolean {
return this.turn === null;
}
/**
* ゲームの勝者 (null = 引き分け)
*/
public get winner(): Color | null {
return this.isEnded ?
this.blackCount == this.whiteCount ? null :
this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
undefined as never;
}
}

View File

@ -0,0 +1,896 @@
/**
* 組み込みマップ定義
*
* データ値:
* (スペース) ... マス無し
* - ... マス
* b ... 初期配置される黒石
* w ... 初期配置される白石
*/
export type Map = {
name?: string;
category?: string;
author?: string;
data: string[];
};
export const fourfour: Map = {
name: '4x4',
category: '4x4',
data: [
'----',
'-wb-',
'-bw-',
'----'
]
};
export const sixsix: Map = {
name: '6x6',
category: '6x6',
data: [
'------',
'------',
'--wb--',
'--bw--',
'------',
'------'
]
};
export const roundedSixsix: Map = {
name: '6x6 rounded',
category: '6x6',
author: 'syuilo',
data: [
' ---- ',
'------',
'--wb--',
'--bw--',
'------',
' ---- '
]
};
export const roundedSixsix2: Map = {
name: '6x6 rounded 2',
category: '6x6',
author: 'syuilo',
data: [
' -- ',
' ---- ',
'--wb--',
'--bw--',
' ---- ',
' -- '
]
};
export const eighteight: Map = {
name: '8x8',
category: '8x8',
data: [
'--------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'--------'
]
};
export const eighteightH1: Map = {
name: '8x8 handicap 1',
category: '8x8',
data: [
'b-------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'--------'
]
};
export const eighteightH2: Map = {
name: '8x8 handicap 2',
category: '8x8',
data: [
'b-------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'-------b'
]
};
export const eighteightH3: Map = {
name: '8x8 handicap 3',
category: '8x8',
data: [
'b------b',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'-------b'
]
};
export const eighteightH4: Map = {
name: '8x8 handicap 4',
category: '8x8',
data: [
'b------b',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'b------b'
]
};
export const eighteightH28: Map = {
name: '8x8 handicap 28',
category: '8x8',
data: [
'bbbbbbbb',
'b------b',
'b------b',
'b--wb--b',
'b--bw--b',
'b------b',
'b------b',
'bbbbbbbb'
]
};
export const roundedEighteight: Map = {
name: '8x8 rounded',
category: '8x8',
author: 'syuilo',
data: [
' ------ ',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
' ------ '
]
};
export const roundedEighteight2: Map = {
name: '8x8 rounded 2',
category: '8x8',
author: 'syuilo',
data: [
' ---- ',
' ------ ',
'--------',
'---wb---',
'---bw---',
'--------',
' ------ ',
' ---- '
]
};
export const roundedEighteight3: Map = {
name: '8x8 rounded 3',
category: '8x8',
author: 'syuilo',
data: [
' -- ',
' ---- ',
' ------ ',
'---wb---',
'---bw---',
' ------ ',
' ---- ',
' -- '
]
};
export const eighteightWithNotch: Map = {
name: '8x8 with notch',
category: '8x8',
author: 'syuilo',
data: [
'--- ---',
'--------',
'--------',
' --wb-- ',
' --bw-- ',
'--------',
'--------',
'--- ---'
]
};
export const eighteightWithSomeHoles: Map = {
name: '8x8 with some holes',
category: '8x8',
author: 'syuilo',
data: [
'--- ----',
'----- --',
'-- -----',
'---wb---',
'---bw- -',
' -------',
'--- ----',
'--------'
]
};
export const circle: Map = {
name: 'Circle',
category: '8x8',
author: 'syuilo',
data: [
' -- ',
' ------ ',
' ------ ',
'---wb---',
'---bw---',
' ------ ',
' ------ ',
' -- '
]
};
export const smile: Map = {
name: 'Smile',
category: '8x8',
author: 'syuilo',
data: [
' ------ ',
'--------',
'-- -- --',
'---wb---',
'-- bw --',
'--- ---',
'--------',
' ------ '
]
};
export const window: Map = {
name: 'Window',
category: '8x8',
author: 'syuilo',
data: [
'--------',
'- -- -',
'- -- -',
'---wb---',
'---bw---',
'- -- -',
'- -- -',
'--------'
]
};
export const reserved: Map = {
name: 'Reserved',
category: '8x8',
author: 'Aya',
data: [
'w------b',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'b------w'
]
};
export const x: Map = {
name: 'X',
category: '8x8',
author: 'Aya',
data: [
'w------b',
'-w----b-',
'--w--b--',
'---wb---',
'---bw---',
'--b--w--',
'-b----w-',
'b------w'
]
};
export const parallel: Map = {
name: 'Parallel',
category: '8x8',
author: 'Aya',
data: [
'--------',
'--------',
'--------',
'---bb---',
'---ww---',
'--------',
'--------',
'--------'
]
};
export const lackOfBlack: Map = {
name: 'Lack of Black',
category: '8x8',
data: [
'--------',
'--------',
'--------',
'---w----',
'---bw---',
'--------',
'--------',
'--------'
]
};
export const squareParty: Map = {
name: 'Square Party',
category: '8x8',
author: 'syuilo',
data: [
'--------',
'-wwwbbb-',
'-w-wb-b-',
'-wwwbbb-',
'-bbbwww-',
'-b-bw-w-',
'-bbbwww-',
'--------'
]
};
export const minesweeper: Map = {
name: 'Minesweeper',
category: '8x8',
author: 'syuilo',
data: [
'b-b--w-w',
'-w-wb-b-',
'w-b--w-b',
'-b-wb-w-',
'-w-bw-b-',
'b-w--b-w',
'-b-bw-w-',
'w-w--b-b'
]
};
export const tenthtenth: Map = {
name: '10x10',
category: '10x10',
data: [
'----------',
'----------',
'----------',
'----------',
'----wb----',
'----bw----',
'----------',
'----------',
'----------',
'----------'
]
};
export const hole: Map = {
name: 'The Hole',
category: '10x10',
author: 'syuilo',
data: [
'----------',
'----------',
'--wb--wb--',
'--bw--bw--',
'---- ----',
'---- ----',
'--wb--wb--',
'--bw--bw--',
'----------',
'----------'
]
};
export const grid: Map = {
name: 'Grid',
category: '10x10',
author: 'syuilo',
data: [
'----------',
'- - -- - -',
'----------',
'- - -- - -',
'----wb----',
'----bw----',
'- - -- - -',
'----------',
'- - -- - -',
'----------'
]
};
export const cross: Map = {
name: 'Cross',
category: '10x10',
author: 'Aya',
data: [
' ---- ',
' ---- ',
' ---- ',
'----------',
'----wb----',
'----bw----',
'----------',
' ---- ',
' ---- ',
' ---- '
]
};
export const charX: Map = {
name: 'Char X',
category: '10x10',
author: 'syuilo',
data: [
'--- ---',
'---- ----',
'----------',
' -------- ',
' --wb-- ',
' --bw-- ',
' -------- ',
'----------',
'---- ----',
'--- ---'
]
};
export const charY: Map = {
name: 'Char Y',
category: '10x10',
author: 'syuilo',
data: [
'--- ---',
'---- ----',
'----------',
' -------- ',
' --wb-- ',
' --bw-- ',
' ------ ',
' ------ ',
' ------ ',
' ------ '
]
};
export const walls: Map = {
name: 'Walls',
category: '10x10',
author: 'Aya',
data: [
' bbbbbbbb ',
'w--------w',
'w--------w',
'w--------w',
'w---wb---w',
'w---bw---w',
'w--------w',
'w--------w',
'w--------w',
' bbbbbbbb '
]
};
export const cpu: Map = {
name: 'CPU',
category: '10x10',
author: 'syuilo',
data: [
' b b b b ',
'w--------w',
' -------- ',
'w--------w',
' ---wb--- ',
' ---bw--- ',
'w--------w',
' -------- ',
'w--------w',
' b b b b '
]
};
export const checker: Map = {
name: 'Checker',
category: '10x10',
author: 'Aya',
data: [
'----------',
'----------',
'----------',
'---wbwb---',
'---bwbw---',
'---wbwb---',
'---bwbw---',
'----------',
'----------',
'----------'
]
};
export const japaneseCurry: Map = {
name: 'Japanese curry',
category: '10x10',
author: 'syuilo',
data: [
'w-b-b-b-b-',
'-w-b-b-b-b',
'w-w-b-b-b-',
'-w-w-b-b-b',
'w-w-wwb-b-',
'-w-wbb-b-b',
'w-w-w-b-b-',
'-w-w-w-b-b',
'w-w-w-w-b-',
'-w-w-w-w-b'
]
};
export const mosaic: Map = {
name: 'Mosaic',
category: '10x10',
author: 'syuilo',
data: [
'- - - - - ',
' - - - - -',
'- - - - - ',
' - w w - -',
'- - b b - ',
' - w w - -',
'- - b b - ',
' - - - - -',
'- - - - - ',
' - - - - -',
]
};
export const arena: Map = {
name: 'Arena',
category: '10x10',
author: 'syuilo',
data: [
'- - -- - -',
' - - - - ',
'- ------ -',
' -------- ',
'- --wb-- -',
'- --bw-- -',
' -------- ',
'- ------ -',
' - - - - ',
'- - -- - -'
]
};
export const reactor: Map = {
name: 'Reactor',
category: '10x10',
author: 'syuilo',
data: [
'-w------b-',
'b- - - -w',
'- --wb-- -',
'---b w---',
'- b wb w -',
'- w bw b -',
'---w b---',
'- --bw-- -',
'w- - - -b',
'-b------w-'
]
};
export const sixeight: Map = {
name: '6x8',
category: 'Special',
data: [
'------',
'------',
'------',
'--wb--',
'--bw--',
'------',
'------',
'------'
]
};
export const spark: Map = {
name: 'Spark',
category: 'Special',
author: 'syuilo',
data: [
' - - ',
'----------',
' -------- ',
' -------- ',
' ---wb--- ',
' ---bw--- ',
' -------- ',
' -------- ',
'----------',
' - - '
]
};
export const islands: Map = {
name: 'Islands',
category: 'Special',
author: 'syuilo',
data: [
'-------- ',
'---wb--- ',
'---bw--- ',
'-------- ',
' - - ',
' - - ',
' --------',
' --------',
' --------',
' --------'
]
};
export const galaxy: Map = {
name: 'Galaxy',
category: 'Special',
author: 'syuilo',
data: [
' ------ ',
' --www--- ',
' ------w--- ',
'---bbb--w---',
'--b---b-w-b-',
'-b--wwb-w-b-',
'-b-w-bww--b-',
'-b-w-b---b--',
'---w--bbb---',
' ---w------ ',
' ---www-- ',
' ------ '
]
};
export const triangle: Map = {
name: 'Triangle',
category: 'Special',
author: 'syuilo',
data: [
' -- ',
' -- ',
' ---- ',
' ---- ',
' --wb-- ',
' --bw-- ',
' -------- ',
' -------- ',
'----------',
'----------'
]
};
export const iphonex: Map = {
name: 'iPhone X',
category: 'Special',
author: 'syuilo',
data: [
' -- -- ',
'--------',
'--------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'--------',
'--------',
' ------ '
]
};
export const dealWithIt: Map = {
name: 'Deal with it!',
category: 'Special',
author: 'syuilo',
data: [
'------------',
'--w-b-------',
' --b-w------',
' --w-b---- ',
' ------- '
]
};
export const experiment: Map = {
name: 'Let\'s experiment',
category: 'Special',
author: 'syuilo',
data: [
' ------------ ',
'------wb------',
'------bw------',
'--------------',
' - - ',
'------ ------',
'bbbbbb wwwwww',
'bbbbbb wwwwww',
'bbbbbb wwwwww',
'bbbbbb wwwwww',
'wwwwww bbbbbb'
]
};
export const bigBoard: Map = {
name: 'Big board',
category: 'Special',
data: [
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'-------wb-------',
'-------bw-------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------'
]
};
export const twoBoard: Map = {
name: 'Two board',
category: 'Special',
author: 'Aya',
data: [
'-------- --------',
'-------- --------',
'-------- --------',
'---wb--- ---wb---',
'---bw--- ---bw---',
'-------- --------',
'-------- --------',
'-------- --------'
]
};
export const test1: Map = {
name: 'Test1',
category: 'Test',
data: [
'--------',
'---wb---',
'---bw---',
'--------'
]
};
export const test2: Map = {
name: 'Test2',
category: 'Test',
data: [
'------',
'------',
'-b--w-',
'-w--b-',
'-w--b-'
]
};
export const test3: Map = {
name: 'Test3',
category: 'Test',
data: [
'-w-',
'--w',
'w--',
'-w-',
'--w',
'w--',
'-w-',
'--w',
'w--',
'-w-',
'---',
'b--',
]
};
export const test4: Map = {
name: 'Test4',
category: 'Test',
data: [
'-w--b-',
'-w--b-',
'------',
'-w--b-',
'-w--b-'
]
};
// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)A1に打ってしまう
export const test6: Map = {
name: 'Test6',
category: 'Test',
data: [
'--wwwww-',
'wwwwwwww',
'wbbbwbwb',
'wbbbbwbb',
'wbwbbwbb',
'wwbwbbbb',
'--wbbbbb',
'-wwwww--',
]
};
// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)G7に打ってしまう
export const test7: Map = {
name: 'Test7',
category: 'Test',
data: [
'b--w----',
'b-wwww--',
'bwbwwwbb',
'wbwwwwb-',
'wwwwwww-',
'-wwbbwwb',
'--wwww--',
'--wwww--',
]
};
// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう
export const test8: Map = {
name: 'Test8',
category: 'Test',
data: [
'--------',
'-----w--',
'w--www--',
'wwwwww--',
'bbbbwww-',
'wwwwww--',
'--www---',
'--ww----',
]
};

View File

@ -0,0 +1,18 @@
{
"name": "misskey-reversi",
"version": "0.0.5",
"description": "Misskey reversi engine",
"keywords": [
"misskey"
],
"author": "syuilo <i@syuilo.com>",
"license": "MIT",
"repository": "https://github.com/misskey-dev/misskey.git",
"bugs": "https://github.com/misskey-dev/misskey/issues",
"main": "./built/core.js",
"types": "./built/core.d.ts",
"scripts": {
"build": "tsc"
},
"dependencies": {}
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"noEmitOnError": false,
"noImplicitAny": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"declaration": true,
"sourceMap": false,
"target": "es2017",
"module": "commonjs",
"removeComments": false,
"noLib": false,
"outDir": "./built",
"rootDir": "./"
},
"compileOnSave": false,
"include": [
"./core.ts"
]
}

View File

@ -0,0 +1,31 @@
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
};
}

View 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(e => e.id === id);
}

View File

@ -0,0 +1,10 @@
// スクリプトサイズがデカい
//import * as crypto from 'crypto';
export default (data: ArrayBuffer) => {
//const buf = new Buffer(data);
//const hash = crypto.createHash('md5');
//hash.update(buf);
//return hash.digest('hex');
return '';
};

View 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.locale.deletedNote})`;
}
if (note.isHidden) {
return `(${i18n.locale.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.locale.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();
};

View File

@ -0,0 +1,16 @@
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;
}
const dummy = `${u.host}${u.pathname}`; // 拡張子がないとキャッシュしてくれないCDNがあるので
return `${instanceUrl}/proxy/${dummy}?${url.query({
url: u.href,
static: '1'
})}`;
}

View File

@ -0,0 +1,205 @@
import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { host } from '@/config';
import * as Acct from 'misskey-js/built/acct';
import * as os from '@/os';
import { userActions } from '@/store';
import { router } from '@/router';
import { $i } from '@/account';
export function getUserMenu(user) {
const meId = $i ? $i.id : null;
async function pushList() {
const t = i18n.locale.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
const lists = await os.api('users/lists/list');
if (lists.length === 0) {
os.dialog({
type: 'error',
text: i18n.locale.youHaveNoLists
});
return;
}
const { canceled, result: listId } = await os.dialog({
type: null,
title: t,
select: {
items: lists.map(list => ({
value: list.id, text: list.name
}))
},
showCancelButton: true
});
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.dialog({
type: 'error',
text: i18n.locale.youHaveNoGroups
});
return;
}
const { canceled, result: groupId } = await os.dialog({
type: null,
title: i18n.locale.group,
select: {
items: groups.map(group => ({
value: group.id, text: group.name
}))
},
showCancelButton: true
});
if (canceled) return;
os.apiWithDialog('users/groups/invite', {
groupId: groupId,
userId: user.id
});
}
async function toggleMute() {
os.apiWithDialog(user.isMuted ? 'mute/delete' : 'mute/create', {
userId: user.id
}).then(() => {
user.isMuted = !user.isMuted;
});
}
async function toggleBlock() {
if (!await getConfirmed(user.isBlocking ? i18n.locale.unblockConfirm : i18n.locale.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(import('@/components/abuse-report-window.vue'), {
user: user,
}, {}, 'closed');
}
async function getConfirmed(text: string): Promise<boolean> {
const confirm = await os.dialog({
type: 'warning',
showCancelButton: true,
title: 'confirm',
text,
});
return !confirm.canceled;
}
let menu = [{
icon: 'fas fa-at',
text: i18n.locale.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host || host}`);
}
}, {
icon: 'fas fa-info-circle',
text: i18n.locale.info,
action: () => {
os.pageWindow(`/user-info/${user.id}`);
}
}, {
icon: 'fas fa-envelope',
text: i18n.locale.sendMessage,
action: () => {
os.post({ specified: user });
}
}, meId != user.id ? {
type: 'link',
icon: 'fas fa-comments',
text: i18n.locale.startMessaging,
to: '/my/messaging/' + Acct.toString(user),
} : undefined, null, {
icon: 'fas fa-list-ul',
text: i18n.locale.addToList,
action: pushList
}, meId != user.id ? {
icon: 'fas fa-users',
text: i18n.locale.inviteToGroup,
action: inviteGroup
} : undefined] as any;
if ($i && meId != user.id) {
menu = menu.concat([null, {
icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
text: user.isMuted ? i18n.locale.unmute : i18n.locale.mute,
action: toggleMute
}, {
icon: 'fas fa-ban',
text: user.isBlocking ? i18n.locale.unblock : i18n.locale.block,
action: toggleBlock
}]);
menu = menu.concat([null, {
icon: 'fas fa-exclamation-circle',
text: i18n.locale.reportAbuse,
action: reportAbuse
}]);
if ($i && ($i.isAdmin || $i.isModerator)) {
menu = menu.concat([null, {
icon: 'fas fa-microphone-slash',
text: user.isSilenced ? i18n.locale.unsilence : i18n.locale.silence,
action: toggleSilence
}, {
icon: 'fas fa-snowflake',
text: user.isSuspended ? i18n.locale.unsuspend : i18n.locale.suspend,
action: toggleSuspend
}]);
}
}
if ($i && meId === user.id) {
menu = menu.concat([null, {
icon: 'fas fa-pencil-alt',
text: i18n.locale.editProfile,
action: () => {
router.push('/settings/profile');
}
}]);
}
if (userActions.length > 0) {
menu = menu.concat([null, ...userActions.map(action => ({
icon: 'fas fa-plug',
text: action.title,
action: () => {
action.handler(user);
}
}))]);
}
return menu;
}

View File

@ -0,0 +1,88 @@
import keyCode from './keycode';
type Keymap = Record<string, Function>;
type Pattern = {
which: string[];
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
};
type Action = {
patterns: Pattern[];
callback: Function;
allowRepeat: boolean;
};
const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
const result = {
patterns: [],
callback: 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(e: KeyboardEvent, patterns: Action['patterns']): boolean {
const key = e.code.toLowerCase();
return patterns.some(pattern => pattern.which.includes(key) &&
pattern.ctrl === e.ctrlKey &&
pattern.shift === e.shiftKey &&
pattern.alt === e.altKey &&
!e.metaKey
);
}
export const makeHotkey = (keymap: Keymap) => {
const actions = parseKeymap(keymap);
return (e: 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(e, action.patterns);
if (matched) {
if (!action.allowRepeat && e.repeat) return;
e.preventDefault();
e.stopPropagation();
action.callback(e);
break;
}
}
};
};

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

View File

@ -0,0 +1,234 @@
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.dialog({
title: q,
input: {}
}).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 (e) {
//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 (e) {}
}
@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 (e) {
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)));
}
}
}

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

View 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: 'fas fa-quote-right', },
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);
}
}
}

View File

@ -0,0 +1,246 @@
import * as tinycolor from 'tinycolor2';
import { Hpml } from './evaluator';
import { values, utils } from '@syuilo/aiscript';
import { Fn, HpmlScope } from '.';
import { Expr } from './expr';
import * as 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: 'fas fa-share-alt', },
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: 'fas fa-plus', },
subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus', },
multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times', },
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: 'fas fa-quote-right', },
strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
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()}`;
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;
}

View File

@ -0,0 +1,189 @@
import autobind from 'autobind-decorator';
import { Type, envVarsDef, PageVar } from '.';
import { Expr, isLiteralValue, Variable } from './expr';
import { funcDefs } from './lib';
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;
}
}

View File

@ -0,0 +1,29 @@
export class I18n<T extends Record<string, any>> {
public locale: T;
constructor(locale: T) {
this.locale = locale;
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, any>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v);
}
}
return str;
} catch (e) {
console.warn(`missing localization '${key}'`);
return key;
}
}
}

View File

@ -0,0 +1,37 @@
// 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) {
try {
await iset('idb-test', 'test');
} catch (e) {
console.error('idb error', e);
idbAvailable = false;
}
}
if (!idbAvailable) 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));
}

View File

@ -0,0 +1,68 @@
import { instance } from '@/instance';
import { $i } from '@/account';
import { api } from '@/os';
import { lang } from '@/config';
export async function initializeSw() {
if (instance.swPublickey &&
('serviceWorker' in navigator) &&
('PushManager' in window) &&
$i && $i.token) {
navigator.serviceWorker.register(`/sw.js`);
navigator.serviceWorker.ready.then(registration => {
registration.active?.postMessage({
msg: 'initialize',
lang,
});
// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(instance.swPublickey)
}).then(subscription => {
function encode(buffer: ArrayBuffer | null) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
}
// Register
api('sw/register', {
endpoint: subscription.endpoint,
auth: encode(subscription.getKey('auth')),
publickey: encode(subscription.getKey('p256dh'))
});
})
// When subscribe failed
.catch(async (err: Error) => {
// 通知が許可されていなかったとき
if (err.name === 'NotAllowedError') {
return;
}
// 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
// 既に存在していることが原因でエラーになった可能性があるので、
// そのサブスクリプションを解除しておく
const subscription = await registration.pushManager.getSubscription();
if (subscription) subscription.unsubscribe();
});
});
}
}
/**
* Convert the URL safe base64 string to a Uint8Array
* @param base64String base64 string
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@ -0,0 +1,3 @@
export function isDeviceDarkmode() {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}

View File

@ -0,0 +1 @@
export const isDeviceTouch = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;

View File

@ -0,0 +1,2 @@
const ua = navigator.userAgent.toLowerCase();
export const isMobile = /mobile|iphone|ipad|android/.test(ua);

View 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'],
};
/*!
* Programatically 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}`];
}

View File

@ -0,0 +1,11 @@
export default {
start: () => {
// TODO
},
done: () => {
// TODO
},
set: val => {
// TODO
}
};

View 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();
}

View File

@ -0,0 +1,37 @@
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.dialog({
title: i18n.locale.usernameOrUserId,
input: true
});
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.dialog({
type: 'error',
text: i18n.locale.noSuchUser
});
} else {
_notFound = true;
}
};
usernamePromise.then(show).catch(e => {
if (e.code === 'NO_SUCH_USER') {
notFound();
}
});
idPromise.then(show).catch(e => {
notFound();
});
}

View File

@ -0,0 +1 @@
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle'];

View File

@ -0,0 +1,246 @@
import { markRaw } from 'vue';
import * as os from '@/os';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
const SECOND_FETCH_LIMIT = 30;
// reversed: items 配列の中身を逆順にする(新しい方が最後)
export default (opts) => ({
emits: ['queue'],
data() {
return {
items: [],
queue: [],
offset: 0,
fetching: true,
moreFetching: false,
inited: false,
more: false,
backed: false, // 遡り中か否か
isBackTop: false,
};
},
computed: {
empty(): boolean {
return this.items.length === 0 && !this.fetching && this.inited;
},
error(): boolean {
return !this.fetching && !this.inited;
},
},
watch: {
pagination: {
handler() {
this.init();
},
deep: true
},
queue: {
handler(a, b) {
if (a.length === 0 && b.length === 0) return;
this.$emit('queue', this.queue.length);
},
deep: true
}
},
created() {
opts.displayLimit = opts.displayLimit || 30;
this.init();
},
activated() {
this.isBackTop = false;
},
deactivated() {
this.isBackTop = window.scrollY === 0;
},
methods: {
reload() {
this.items = [];
this.init();
},
replaceItem(finder, data) {
const i = this.items.findIndex(finder);
this.items[i] = data;
},
removeItem(finder) {
const i = this.items.findIndex(finder);
this.items.splice(i, 1);
},
async init() {
this.queue = [];
this.fetching = true;
if (opts.before) opts.before(this);
let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
if (params && params.then) params = await params;
if (params === null) return;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
}).then(items => {
for (let i = 0; i < items.length; i++) {
const item = items[i];
markRaw(item);
if (this.pagination.reversed) {
if (i === items.length - 2) item._shouldInsertAd_ = true;
} else {
if (i === 3) item._shouldInsertAd_ = true;
}
}
if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse() : items;
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse() : items;
this.more = false;
}
this.offset = items.length;
this.inited = true;
this.fetching = false;
if (opts.after) opts.after(this, null);
}, e => {
this.fetching = false;
if (opts.after) opts.after(this, e);
});
},
async fetchMore() {
if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
this.moreFetching = true;
this.backed = true;
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : {
untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (let i = 0; i < items.length; i++) {
const item = items[i];
markRaw(item);
if (this.pagination.reversed) {
if (i === items.length - 9) item._shouldInsertAd_ = true;
} else {
if (i === 10) item._shouldInsertAd_ = true;
}
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false;
}
this.offset += items.length;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
async fetchMoreFeature() {
if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
this.moreFetching = true;
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : {
sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (const item of items) {
markRaw(item);
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false;
}
this.offset += items.length;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
prepend(item) {
if (this.pagination.reversed) {
const container = getScrollContainer(this.$el);
const pos = getScrollPosition(this.$el);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
if (isBottom) {
// オーバーフローしたら古いアイテムは捨てる
if (this.items.length >= opts.displayLimit) {
// このやり方だとVue 3.2以降アニメーションが動かなくなる
//this.items = this.items.slice(-opts.displayLimit);
while (this.items.length >= opts.displayLimit) {
this.items.shift();
}
this.more = true;
}
}
this.items.push(item);
// TODO
} else {
const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
if (isTop) {
// Prepend the item
this.items.unshift(item);
// オーバーフローしたら古いアイテムは捨てる
if (this.items.length >= opts.displayLimit) {
// このやり方だとVue 3.2以降アニメーションが動かなくなる
//this.items = this.items.slice(0, opts.displayLimit);
while (this.items.length >= opts.displayLimit) {
this.items.pop();
}
this.more = true;
}
} else {
this.queue.push(item);
onScrollTop(this.$el, () => {
for (const item of this.queue) {
this.prepend(item);
}
this.queue = [];
});
}
}
},
append(item) {
this.items.push(item);
},
}
});

View 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);
const objs = [];
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;
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;
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 = 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);
clearInterval(intervalId);
}
};
}

View File

@ -0,0 +1,14 @@
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { dialog } from '@/os';
export function pleaseLogin() {
if ($i) return;
dialog({
title: i18n.locale.signinRequired,
text: null
});
throw new Error('signin required');
}

View File

@ -0,0 +1,22 @@
import * as config from '@/config';
export function popout(path: string, w?: HTMLElement) {
let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
url += '?zen'; // TODO: ちゃんとURLパースしてクエリ付ける
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}`);
}
}

View File

@ -0,0 +1,41 @@
import { Ref, ref } from 'vue';
import { popup } from '@/os';
class ReactionPicker {
private src: Ref<HTMLElement | null> = ref(null);
private manualShowing = ref(false);
private onChosen?: Function;
private onClosed?: Function;
constructor() {
// nop
}
public async init() {
await popup(import('@/components/emoji-picker-dialog.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: Function, onClosed: Function) {
this.src.value = src;
this.manualShowing.value = true;
this.onChosen = onChosen;
this.onClosed = onClosed;
}
}
export const reactionPicker = new ReactionPicker();

View File

@ -0,0 +1,21 @@
export type RoomInfo = {
roomType: string;
carpetColor: string;
furnitures: Furniture[];
};
export type Furniture = {
id: string; // 同じ家具が複数ある場合にそれぞれを識別するためのIDであり、家具IDではない
type: string; // こっちが家具ID(chairとか)
position: {
x: number;
y: number;
z: number;
};
rotation: {
x: number;
y: number;
z: number;
};
props?: Record<string, any>;
};

View File

@ -0,0 +1,407 @@
// 家具メタデータ
// 家具IDはglbファイル及びそのディレクトリ名と一致する必要があります
// 家具にはユーザーが設定できるプロパティを設定可能です:
//
// props: {
// <propname>: <proptype>
// }
//
// proptype一覧:
// * image ... 画像選択ダイアログを出し、その画像のURLが格納されます
// * color ... 色選択コントロールを出し、選択された色が格納されます
// 家具にカスタムテクスチャを適用できるようにするには、textureプロパティに以下の追加の情報を含めます:
// 便宜上そのUVのどの部分にカスタムテクスチャを貼り合わせるかのエリアをテクスチャエリアと呼びます。
// UVは1024*1024だと仮定します。
//
// <key>: {
// prop: <プロパティ名>,
// uv: {
// x: <テクスチャエリアX座標>,
// y: <テクスチャエリアY座標>,
// width: <テクスチャエリアの幅>,
// height: <テクスチャエリアの高さ>,
// },
// }
//
// <key>には、カスタムテクスチャを適用したいメッシュ名を指定します
// <プロパティ名>には、カスタムテクスチャとして使用する画像を格納するプロパティ(前述)名を指定します
// 家具にカスタムカラーを適用できるようにするには、colorプロパティに以下の追加の情報を含めます:
//
// <key>: <プロパティ名>
//
// <key>には、カスタムカラーを適用したいマテリアル名を指定します
// <プロパティ名>には、カスタムカラーとして使用する色を格納するプロパティ(前述)名を指定します
[
{
id: "milk",
place: "floor"
},
{
id: "bed",
place: "floor"
},
{
id: "low-table",
place: "floor",
props: {
color: 'color'
},
color: {
Table: 'color'
}
},
{
id: "desk",
place: "floor",
props: {
color: 'color'
},
color: {
Board: 'color'
}
},
{
id: "chair",
place: "floor",
props: {
color: 'color'
},
color: {
Chair: 'color'
}
},
{
id: "chair2",
place: "floor",
props: {
color1: 'color',
color2: 'color'
},
color: {
Cushion: 'color1',
Leg: 'color2'
}
},
{
id: "fan",
place: "wall"
},
{
id: "pc",
place: "floor"
},
{
id: "plant",
place: "floor"
},
{
id: "plant2",
place: "floor"
},
{
id: "eraser",
place: "floor"
},
{
id: "pencil",
place: "floor"
},
{
id: "pudding",
place: "floor"
},
{
id: "cardboard-box",
place: "floor"
},
{
id: "cardboard-box2",
place: "floor"
},
{
id: "cardboard-box3",
place: "floor"
},
{
id: "book",
place: "floor",
props: {
color: 'color'
},
color: {
Cover: 'color'
}
},
{
id: "book2",
place: "floor"
},
{
id: "piano",
place: "floor"
},
{
id: "facial-tissue",
place: "floor"
},
{
id: "server",
place: "floor"
},
{
id: "moon",
place: "floor"
},
{
id: "corkboard",
place: "wall"
},
{
id: "mousepad",
place: "floor",
props: {
color: 'color'
},
color: {
Pad: 'color'
}
},
{
id: "monitor",
place: "floor",
props: {
screen: 'image'
},
texture: {
Screen: {
prop: 'screen',
uv: {
x: 0,
y: 434,
width: 1024,
height: 588,
},
},
},
},
{
id: "tv",
place: "floor",
props: {
screen: 'image'
},
texture: {
Screen: {
prop: 'screen',
uv: {
x: 0,
y: 434,
width: 1024,
height: 588,
},
},
},
},
{
id: "keyboard",
place: "floor"
},
{
id: "carpet-stripe",
place: "floor",
props: {
color1: 'color',
color2: 'color'
},
color: {
CarpetAreaA: 'color1',
CarpetAreaB: 'color2'
},
},
{
id: "mat",
place: "floor",
props: {
color: 'color'
},
color: {
Mat: 'color'
}
},
{
id: "color-box",
place: "floor",
props: {
color: 'color'
},
color: {
main: 'color'
}
},
{
id: "wall-clock",
place: "wall"
},
{
id: "cube",
place: "floor",
props: {
color: 'color'
},
color: {
Cube: 'color'
}
},
{
id: "photoframe",
place: "wall",
props: {
photo: 'image',
color: 'color'
},
texture: {
Photo: {
prop: 'photo',
uv: {
x: 0,
y: 342,
width: 1024,
height: 683,
},
},
},
color: {
Frame: 'color'
}
},
{
id: "pinguin",
place: "floor",
props: {
body: 'color',
belly: 'color'
},
color: {
Body: 'body',
Belly: 'belly',
}
},
{
id: "rubik-cube",
place: "floor",
},
{
id: "poster-h",
place: "wall",
props: {
picture: 'image'
},
texture: {
Poster: {
prop: 'picture',
uv: {
x: 0,
y: 277,
width: 1024,
height: 745,
},
},
},
},
{
id: "poster-v",
place: "wall",
props: {
picture: 'image'
},
texture: {
Poster: {
prop: 'picture',
uv: {
x: 0,
y: 0,
width: 745,
height: 1024,
},
},
},
},
{
id: "sofa",
place: "floor",
props: {
color: 'color'
},
color: {
Sofa: 'color'
}
},
{
id: "spiral",
place: "floor",
props: {
color: 'color'
},
color: {
Step: 'color'
}
},
{
id: "bin",
place: "floor",
props: {
color: 'color'
},
color: {
Bin: 'color'
}
},
{
id: "cup-noodle",
place: "floor"
},
{
id: "holo-display",
place: "floor",
props: {
image: 'image'
},
texture: {
Image_Front: {
prop: 'image',
uv: {
x: 0,
y: 0,
width: 1024,
height: 1024,
},
},
Image_Back: {
prop: 'image',
uv: {
x: 0,
y: 0,
width: 1024,
height: 1024,
},
},
},
},
{
id: 'energy-drink',
place: "floor",
},
{
id: 'doll-ai',
place: "floor",
},
{
id: 'banknote',
place: "floor",
},
]

View File

@ -0,0 +1,775 @@
import autobind from 'autobind-decorator';
import { v4 as uuid } from 'uuid';
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { Furniture, RoomInfo } from './furniture';
import { query as urlQuery } from '@/scripts/url';
const furnitureDefs = require('./furnitures.json5');
THREE.ImageUtils.crossOrigin = '';
type Options = {
graphicsQuality: Room['graphicsQuality'];
onChangeSelect: Room['onChangeSelect'];
useOrthographicCamera: boolean;
};
/**
* MisskeyRoom Core Engine
*/
export class Room {
private clock: THREE.Clock;
private scene: THREE.Scene;
private renderer: THREE.WebGLRenderer;
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
private controls: OrbitControls;
private composer: EffectComposer;
private mixers: THREE.AnimationMixer[] = [];
private furnitureControl: TransformControls;
private roomInfo: RoomInfo;
private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra';
private roomObj: THREE.Object3D;
private objects: THREE.Object3D[] = [];
private selectedObject: THREE.Object3D = null;
private onChangeSelect: Function;
private isTransformMode = false;
private renderFrameRequestId: number;
private get canvas(): HTMLCanvasElement {
return this.renderer.domElement;
}
private get furnitures(): Furniture[] {
return this.roomInfo.furnitures;
}
private set furnitures(furnitures: Furniture[]) {
this.roomInfo.furnitures = furnitures;
}
private get enableShadow() {
return this.graphicsQuality != 'cheep';
}
private get usePostFXs() {
return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low';
}
private get shadowQuality() {
return (
this.graphicsQuality === 'ultra' ? 16384 :
this.graphicsQuality === 'high' ? 8192 :
this.graphicsQuality === 'medium' ? 4096 :
this.graphicsQuality === 'low' ? 1024 :
0); // cheep
}
constructor(user, isMyRoom, roomInfo: RoomInfo, container: Element, options: Options) {
this.roomInfo = roomInfo;
this.graphicsQuality = options.graphicsQuality;
this.onChangeSelect = options.onChangeSelect;
this.clock = new THREE.Clock(true);
//#region Init a scene
this.scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
//#region Init a renderer
this.renderer = new THREE.WebGLRenderer({
antialias: false,
stencil: false,
alpha: false,
powerPreference:
this.graphicsQuality === 'ultra' ? 'high-performance' :
this.graphicsQuality === 'high' ? 'high-performance' :
this.graphicsQuality === 'medium' ? 'default' :
this.graphicsQuality === 'low' ? 'low-power' :
'low-power' // cheep
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(width, height);
this.renderer.autoClear = false;
this.renderer.setClearColor(new THREE.Color(0x051f2d));
this.renderer.shadowMap.enabled = this.enableShadow;
this.renderer.shadowMap.type =
this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap :
this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap :
this.graphicsQuality === 'medium' ? THREE.PCFShadowMap :
this.graphicsQuality === 'low' ? THREE.BasicShadowMap :
THREE.BasicShadowMap; // cheep
container.insertBefore(this.canvas, container.firstChild);
//#endregion
//#region Init a camera
this.camera = options.useOrthographicCamera
? new THREE.OrthographicCamera(
width / - 2, width / 2, height / 2, height / - 2, -10, 10)
: new THREE.PerspectiveCamera(45, width / height);
if (options.useOrthographicCamera) {
this.camera.position.x = 2;
this.camera.position.y = 2;
this.camera.position.z = 2;
this.camera.zoom = 100;
this.camera.updateProjectionMatrix();
} else {
this.camera.position.x = 5;
this.camera.position.y = 2;
this.camera.position.z = 5;
}
this.scene.add(this.camera);
//#endregion
//#region AmbientLight
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
this.scene.add(ambientLight);
//#endregion
if (this.graphicsQuality !== 'cheep') {
//#region Room light
const roomLight = new THREE.SpotLight(0xffffff, 0.1);
roomLight.position.set(0, 8, 0);
roomLight.castShadow = this.enableShadow;
roomLight.shadow.bias = -0.0001;
roomLight.shadow.mapSize.width = this.shadowQuality;
roomLight.shadow.mapSize.height = this.shadowQuality;
roomLight.shadow.camera.near = 0.1;
roomLight.shadow.camera.far = 9;
roomLight.shadow.camera.fov = 45;
this.scene.add(roomLight);
//#endregion
}
//#region Out light
const outLight1 = new THREE.SpotLight(0xffffff, 0.4);
outLight1.position.set(9, 3, -2);
outLight1.castShadow = this.enableShadow;
outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
outLight1.shadow.mapSize.width = this.shadowQuality;
outLight1.shadow.mapSize.height = this.shadowQuality;
outLight1.shadow.camera.near = 6;
outLight1.shadow.camera.far = 15;
outLight1.shadow.camera.fov = 45;
this.scene.add(outLight1);
const outLight2 = new THREE.SpotLight(0xffffff, 0.2);
outLight2.position.set(-2, 3, 9);
outLight2.castShadow = false;
outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
outLight2.shadow.camera.near = 6;
outLight2.shadow.camera.far = 15;
outLight2.shadow.camera.fov = 45;
this.scene.add(outLight2);
//#endregion
//#region Init a controller
this.controls = new OrbitControls(this.camera, this.canvas);
this.controls.target.set(0, 1, 0);
this.controls.enableZoom = true;
this.controls.enablePan = isMyRoom;
this.controls.minPolarAngle = 0;
this.controls.maxPolarAngle = Math.PI / 2;
this.controls.minAzimuthAngle = 0;
this.controls.maxAzimuthAngle = Math.PI / 2;
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.2;
//#endregion
//#region POST FXs
if (!this.usePostFXs) {
this.composer = null;
} else {
const renderTarget = new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBFormat,
stencilBuffer: false,
});
const fxaa = new ShaderPass(FXAAShader);
fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height);
fxaa.renderToScreen = true;
this.composer = new EffectComposer(this.renderer, renderTarget);
this.composer.addPass(new RenderPass(this.scene, this.camera));
if (this.graphicsQuality === 'ultra') {
this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512));
}
this.composer.addPass(fxaa);
}
//#endregion
//#endregion
//#region Label
//#region Avatar
const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`;
const textureLoader = new THREE.TextureLoader();
textureLoader.crossOrigin = 'anonymous';
const iconTexture = textureLoader.load(avatarUrl);
iconTexture.wrapS = THREE.RepeatWrapping;
iconTexture.wrapT = THREE.RepeatWrapping;
iconTexture.anisotropy = 16;
const avatarMaterial = new THREE.MeshBasicMaterial({
map: iconTexture,
side: THREE.DoubleSide,
alphaTest: 0.5
});
const iconGeometry = new THREE.PlaneGeometry(1, 1);
const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial);
avatarObject.position.set(-3, 2.5, 2);
avatarObject.rotation.y = Math.PI / 2;
avatarObject.castShadow = false;
this.scene.add(avatarObject);
//#endregion
//#region Username
const name = user.username;
new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => {
const nameGeometry = new THREE.TextGeometry(name, {
size: 0.5,
height: 0,
curveSegments: 8,
font: font,
bevelThickness: 0,
bevelSize: 0,
bevelEnabled: false
});
const nameMaterial = new THREE.MeshLambertMaterial({
color: 0xffffff
});
const nameObject = new THREE.Mesh(nameGeometry, nameMaterial);
nameObject.position.set(-3, 2.25, 1.25);
nameObject.rotation.y = Math.PI / 2;
nameObject.castShadow = false;
this.scene.add(nameObject);
});
//#endregion
//#endregion
//#region Interaction
if (isMyRoom) {
this.furnitureControl = new TransformControls(this.camera, this.canvas);
this.scene.add(this.furnitureControl);
// Hover highlight
this.canvas.onmousemove = this.onmousemove;
// Click
this.canvas.onmousedown = this.onmousedown;
}
//#endregion
//#region Init room
this.loadRoom();
//#endregion
//#region Load furnitures
for (const furniture of this.furnitures) {
this.loadFurniture(furniture).then(obj => {
this.scene.add(obj.scene);
this.objects.push(obj.scene);
});
}
//#endregion
// Start render
if (this.usePostFXs) {
this.renderWithPostFXs();
} else {
this.renderWithoutPostFXs();
}
}
@autobind
private renderWithoutPostFXs() {
this.renderFrameRequestId =
window.requestAnimationFrame(this.renderWithoutPostFXs);
// Update animations
const clock = this.clock.getDelta();
for (const mixer of this.mixers) {
mixer.update(clock);
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
@autobind
private renderWithPostFXs() {
this.renderFrameRequestId =
window.requestAnimationFrame(this.renderWithPostFXs);
// Update animations
const clock = this.clock.getDelta();
for (const mixer of this.mixers) {
mixer.update(clock);
}
this.controls.update();
this.renderer.clear();
this.composer.render();
}
@autobind
private loadRoom() {
const type = this.roomInfo.roomType;
new GLTFLoader().load(`/client-assets/room/rooms/${type}/${type}.glb`, gltf => {
gltf.scene.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
child.receiveShadow = this.enableShadow;
child.material = new THREE.MeshLambertMaterial({
color: (child.material as THREE.MeshStandardMaterial).color,
map: (child.material as THREE.MeshStandardMaterial).map,
name: (child.material as THREE.MeshStandardMaterial).name,
});
// 異方性フィルタリング
if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') {
(child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshLambertMaterial).map.anisotropy = 8;
}
});
gltf.scene.position.set(0, 0, 0);
this.scene.add(gltf.scene);
this.roomObj = gltf.scene;
if (this.roomInfo.roomType === 'default') {
this.applyCarpetColor();
}
});
}
@autobind
private loadFurniture(furniture: Furniture) {
const def = furnitureDefs.find(d => d.id === furniture.type);
return new Promise<GLTF>((res, rej) => {
const loader = new GLTFLoader();
loader.load(`/client-assets/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => {
const model = gltf.scene;
// Load animation
if (gltf.animations.length > 0) {
const mixer = new THREE.AnimationMixer(model);
this.mixers.push(mixer);
for (const clip of gltf.animations) {
mixer.clipAction(clip).play();
}
}
model.name = furniture.id;
model.position.x = furniture.position.x;
model.position.y = furniture.position.y;
model.position.z = furniture.position.z;
model.rotation.x = furniture.rotation.x;
model.rotation.y = furniture.rotation.y;
model.rotation.z = furniture.rotation.z;
model.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
child.castShadow = this.enableShadow;
child.receiveShadow = this.enableShadow;
(child.material as THREE.MeshStandardMaterial).metalness = 0;
// 異方性フィルタリング
if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') {
(child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
(child.material as THREE.MeshStandardMaterial).map.anisotropy = 8;
}
});
if (def.color) { // カスタムカラー
this.applyCustomColor(model);
}
if (def.texture) { // カスタムテクスチャ
this.applyCustomTexture(model);
}
res(gltf);
}, null, rej);
});
}
@autobind
private applyCarpetColor() {
this.roomObj.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
if (child.material &&
(child.material as THREE.MeshStandardMaterial).name &&
(child.material as THREE.MeshStandardMaterial).name === 'Carpet'
) {
const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16);
(child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
}
});
}
@autobind
private applyCustomColor(model: THREE.Object3D) {
const furniture = this.furnitures.find(furniture => furniture.id === model.name);
const def = furnitureDefs.find(d => d.id === furniture.type);
if (def.color == null) return;
model.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
for (const t of Object.keys(def.color)) {
if (!child.material ||
!(child.material as THREE.MeshStandardMaterial).name ||
(child.material as THREE.MeshStandardMaterial).name !== t
) continue;
const prop = def.color[t];
const val = furniture.props ? furniture.props[prop] : undefined;
if (val == null) continue;
const colorHex = parseInt(val.substr(1), 16);
(child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
}
});
}
@autobind
private applyCustomTexture(model: THREE.Object3D) {
const furniture = this.furnitures.find(furniture => furniture.id === model.name);
const def = furnitureDefs.find(d => d.id === furniture.type);
if (def.texture == null) return;
model.traverse(child => {
if (!(child instanceof THREE.Mesh)) return;
for (const t of Object.keys(def.texture)) {
if (child.name !== t) continue;
const prop = def.texture[t].prop;
const val = furniture.props ? furniture.props[prop] : undefined;
if (val == null) continue;
const canvas = document.createElement('canvas');
canvas.height = 1024;
canvas.width = 1024;
child.material = new THREE.MeshLambertMaterial({
emissive: 0x111111,
side: THREE.DoubleSide,
alphaTest: 0.5,
});
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const uvInfo = def.texture[t].uv;
const ctx = canvas.getContext('2d');
ctx.drawImage(img,
0, 0, img.width, img.height,
uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height);
const texture = new THREE.Texture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = 16;
texture.flipY = false;
(child.material as THREE.MeshLambertMaterial).map = texture;
(child.material as THREE.MeshLambertMaterial).needsUpdate = true;
(child.material as THREE.MeshLambertMaterial).map.needsUpdate = true;
};
img.src = val;
}
});
}
@autobind
private onmousemove(ev: MouseEvent) {
if (this.isTransformMode) return;
const rect = (ev.target as HTMLElement).getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
const pos = new THREE.Vector2(x, y);
this.camera.updateMatrixWorld();
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pos, this.camera);
const intersects = raycaster.intersectObjects(this.objects, true);
for (const object of this.objects) {
if (this.isSelectedObject(object)) continue;
object.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
}
});
}
if (intersects.length > 0) {
const intersected = this.getRoot(intersects[0].object);
if (this.isSelectedObject(intersected)) return;
intersected.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919);
}
});
}
}
@autobind
private onmousedown(ev: MouseEvent) {
if (this.isTransformMode) return;
if (ev.target !== this.canvas || ev.button !== 0) return;
const rect = (ev.target as HTMLElement).getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
const pos = new THREE.Vector2(x, y);
this.camera.updateMatrixWorld();
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pos, this.camera);
const intersects = raycaster.intersectObjects(this.objects, true);
for (const object of this.objects) {
object.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
}
});
}
if (intersects.length > 0) {
const selectedObj = this.getRoot(intersects[0].object);
this.selectFurniture(selectedObj);
} else {
this.selectedObject = null;
this.onChangeSelect(null);
}
}
@autobind
private getRoot(obj: THREE.Object3D): THREE.Object3D {
let found = false;
let x = obj.parent;
while (!found) {
if (x.parent.parent == null) {
found = true;
} else {
x = x.parent;
}
}
return x;
}
@autobind
private isSelectedObject(obj: THREE.Object3D): boolean {
if (this.selectedObject == null) {
return false;
} else {
return obj.name === this.selectedObject.name;
}
}
@autobind
private selectFurniture(obj: THREE.Object3D) {
this.selectedObject = obj;
this.onChangeSelect(obj);
obj.traverse(child => {
if (child instanceof THREE.Mesh) {
(child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000);
}
});
}
/**
* 家具の移動/回転モードにします
* @param type 移動か回転か
*/
@autobind
public enterTransformMode(type: 'translate' | 'rotate') {
this.isTransformMode = true;
this.furnitureControl.setMode(type);
this.furnitureControl.attach(this.selectedObject);
this.controls.enableRotate = false;
}
/**
* 家具の移動/回転モードを終了します
*/
@autobind
public exitTransformMode() {
this.isTransformMode = false;
this.furnitureControl.detach();
this.controls.enableRotate = true;
}
/**
* 家具プロパティを更新します
* @param key プロパティ名
* @param value 値
*/
@autobind
public updateProp(key: string, value: any) {
const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name);
if (furniture.props == null) furniture.props = {};
furniture.props[key] = value;
this.applyCustomColor(this.selectedObject);
this.applyCustomTexture(this.selectedObject);
}
/**
* 部屋に家具を追加します
* @param type 家具の種類
*/
@autobind
public addFurniture(type: string) {
const furniture = {
id: uuid(),
type: type,
position: {
x: 0,
y: 0,
z: 0,
},
rotation: {
x: 0,
y: 0,
z: 0,
},
};
this.furnitures.push(furniture);
this.loadFurniture(furniture).then(obj => {
this.scene.add(obj.scene);
this.objects.push(obj.scene);
});
}
/**
* 現在選択されている家具を部屋から削除します
*/
@autobind
public removeFurniture() {
this.exitTransformMode();
const obj = this.selectedObject;
this.scene.remove(obj);
this.objects = this.objects.filter(object => object.name !== obj.name);
this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name);
this.selectedObject = null;
this.onChangeSelect(null);
}
/**
* 全ての家具を部屋から削除します
*/
@autobind
public removeAllFurnitures() {
this.exitTransformMode();
for (const obj of this.objects) {
this.scene.remove(obj);
}
this.objects = [];
this.furnitures = [];
this.selectedObject = null;
this.onChangeSelect(null);
}
/**
* 部屋の床の色を変更します
* @param color 色
*/
@autobind
public updateCarpetColor(color: string) {
this.roomInfo.carpetColor = color;
this.applyCarpetColor();
}
/**
* 部屋の種類を変更します
* @param type 種類
*/
@autobind
public changeRoomType(type: string) {
this.roomInfo.roomType = type;
this.scene.remove(this.roomObj);
this.loadRoom();
}
/**
* 部屋データを取得します
*/
@autobind
public getRoomInfo() {
for (const obj of this.objects) {
const furniture = this.furnitures.find(f => f.id === obj.name);
furniture.position.x = obj.position.x;
furniture.position.y = obj.position.y;
furniture.position.z = obj.position.z;
furniture.rotation.x = obj.rotation.x;
furniture.rotation.y = obj.rotation.y;
furniture.rotation.z = obj.rotation.z;
}
return this.roomInfo;
}
/**
* 選択されている家具を取得します
*/
@autobind
public getSelectedObject() {
return this.selectedObject;
}
@autobind
public findFurnitureById(id: string) {
return this.furnitures.find(furniture => furniture.id === id);
}
/**
* レンダリングを終了します
*/
@autobind
public destroy() {
// Stop render loop
window.cancelAnimationFrame(this.renderFrameRequestId);
this.controls.dispose();
this.scene.dispose();
}
}

View File

@ -0,0 +1,80 @@
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
export function getScrollContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden 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 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);
}

View File

@ -0,0 +1,64 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
import { router } from '@/router';
export async function search() {
const { canceled, result: query } = await os.dialog({
title: i18n.locale.search,
input: true
});
if (canceled || query == null || query === '') return;
const q = query.trim();
if (q.startsWith('@') && !q.includes(' ')) {
router.push(`/${q}`);
return;
}
if (q.startsWith('#')) {
router.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.dialog({
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.locale.fetchingAsApObject);
const res = await promise;
if (res.type === 'User') {
router.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
router.push(`/notes/${res.object.id}`);
}
return;
}
router.push(`/search?q=${encodeURIComponent(q)}`);
}

View File

@ -0,0 +1,89 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
export function selectFile(src: any, label: string | null, multiple = false) {
return new Promise((res, rej) => {
const chooseFileFromPc = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
input.onchange = () => {
const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder));
Promise.all(promises).then(driveFiles => {
res(multiple ? driveFiles : driveFiles[0]);
}).catch(e => {
os.dialog({
type: 'error',
text: e
});
});
// 一応廃棄
(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.dialog({
title: i18n.locale.uploadFromUrl,
input: {
placeholder: i18n.locale.uploadFromUrlDescription
}
}).then(({ canceled, result: url }) => {
if (canceled) return;
const marker = Math.random().toString(); // TODO: UUIDとか使う
const connection = os.stream.useChannel('main');
connection.on('urlUploadFinished', data => {
if (data.marker === marker) {
res(multiple ? [data.file] : data.file);
connection.dispose();
}
});
os.api('drive/files/upload-from-url', {
url: url,
folderId: defaultStore.state.uploadFolder,
marker
});
os.dialog({
title: i18n.locale.uploadFromUrlRequested,
text: i18n.locale.uploadFromUrlMayTakeTime
});
});
};
os.popupMenu([label ? {
text: label,
type: 'label'
} : undefined, {
text: i18n.locale.upload,
icon: 'fas fa-upload',
action: chooseFileFromPc
}, {
text: i18n.locale.fromDrive,
icon: 'fas fa-cloud',
action: chooseFileFromDrive
}, {
text: i18n.locale.fromUrl,
icon: 'fas fa-link',
action: chooseFileFromUrl
}], src);
});
}

View File

@ -0,0 +1,10 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
export function showSuspendedDialog() {
return os.dialog({
type: 'error',
title: i18n.locale.yourAccountSuspendedTitle,
text: i18n.locale.yourAccountSuspendedDescription
});
}

View File

@ -0,0 +1,34 @@
import { ColdDeviceStorage } from '@/store';
const cache = new Map<string, HTMLAudioElement>();
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();
}

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

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

View File

@ -0,0 +1,127 @@
import { globalEvents } from '@/events';
import * as tinycolor from 'tinycolor2';
export type Theme = {
id: string;
name: string;
author: string;
desc?: string;
base?: 'dark' | 'light';
props: Record<string, string>;
};
export const lightTheme: Theme = require('@/themes/_light.json5');
export const darkTheme: Theme = require('@/themes/_dark.json5');
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const builtinThemes = [
require('@/themes/l-light.json5'),
require('@/themes/l-apricot.json5'),
require('@/themes/l-rainy.json5'),
require('@/themes/l-vivid.json5'),
require('@/themes/l-sushi.json5'),
require('@/themes/d-dark.json5'),
require('@/themes/d-persimmon.json5'),
require('@/themes/d-astro.json5'),
require('@/themes/d-future.json5'),
require('@/themes/d-botanical.json5'),
require('@/themes/d-pumpkin.json5'),
require('@/themes/d-black.json5'),
] as Theme[];
let timeout = null;
export function applyTheme(theme: Theme, persist = true) {
if (timeout) clearTimeout(timeout);
document.documentElement.classList.add('_themeChanging_');
timeout = setTimeout(() => {
document.documentElement.classList.remove('_themeChanging_');
}, 1000);
// Deep copy
const _theme = JSON.parse(JSON.stringify(theme));
if (_theme.base) {
const base = [lightTheme, darkTheme].find(x => x.id === _theme.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['html']);
break;
}
}
for (const [k, v] of Object.entries(props)) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
}
if (persist) {
localStorage.setItem('theme', JSON.stringify(props));
}
// 色計算など再度行えるようにクライアント全体に通知
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;
}

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

View File

@ -0,0 +1 @@
export const twemojiSvgBase = '/twemoji';

View 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();
}
}

View File

@ -0,0 +1,13 @@
export function query(obj: {}): 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((e) => `${e[0]}=${encodeURIComponent(e[1])}`)
.join('&');
}
export function appendQuery(url: string, query: string): string {
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
}