diff options
Diffstat (limited to 'src/client/scripts')
| -rw-r--r-- | src/client/scripts/aiscript/api.ts | 60 | ||||
| -rw-r--r-- | src/client/scripts/autocomplete.ts | 251 | ||||
| -rw-r--r-- | src/client/scripts/extract-avg-color-from-blurhash.ts | 9 | ||||
| -rw-r--r-- | src/client/scripts/focus.ts | 12 | ||||
| -rw-r--r-- | src/client/scripts/gen-search-query.ts | 4 | ||||
| -rw-r--r-- | src/client/scripts/get-static-image-url.ts | 2 | ||||
| -rw-r--r-- | src/client/scripts/get-user-menu.ts | 194 | ||||
| -rw-r--r-- | src/client/scripts/hotkey.ts | 116 | ||||
| -rw-r--r-- | src/client/scripts/hpml/evaluator.ts | 9 | ||||
| -rw-r--r-- | src/client/scripts/loading.ts | 16 | ||||
| -rw-r--r-- | src/client/scripts/paging.ts | 74 | ||||
| -rw-r--r-- | src/client/scripts/please-login.ts | 14 | ||||
| -rw-r--r-- | src/client/scripts/popout.ts | 22 | ||||
| -rw-r--r-- | src/client/scripts/search.ts | 38 | ||||
| -rw-r--r-- | src/client/scripts/select-drive-file.ts | 13 | ||||
| -rw-r--r-- | src/client/scripts/select-drive-folder.ts | 13 | ||||
| -rw-r--r-- | src/client/scripts/select-file.ts | 99 | ||||
| -rw-r--r-- | src/client/scripts/set-i18n-contexts.ts | 6 | ||||
| -rw-r--r-- | src/client/scripts/stream.ts | 14 | ||||
| -rw-r--r-- | src/client/scripts/theme-editor.ts | 17 | ||||
| -rw-r--r-- | src/client/scripts/theme.ts | 2 |
21 files changed, 643 insertions, 342 deletions
diff --git a/src/client/scripts/aiscript/api.ts b/src/client/scripts/aiscript/api.ts index 7e3a668871..f5618bd14c 100644 --- a/src/client/scripts/aiscript/api.ts +++ b/src/client/scripts/aiscript/api.ts @@ -1,22 +1,22 @@ import { utils, values } from '@syuilo/aiscript'; -import { jsToVal } from '@syuilo/aiscript/built/interpreter/util'; +import { store } from '@/store'; +import * as os from '@/os'; -// TODO: vue3に移行した折にはvmを渡す必要は無くなるはず -export function createAiScriptEnv(vm, opts) { +export function createAiScriptEnv(opts) { let apiRequests = 0; return { - USER_ID: vm.$store.getters.isSignedIn ? values.STR(vm.$store.state.i.id) : values.NULL, - USER_NAME: vm.$store.getters.isSignedIn ? values.STR(vm.$store.state.i.name) : values.NULL, - USER_USERNAME: vm.$store.getters.isSignedIn ? values.STR(vm.$store.state.i.username) : values.NULL, + USER_ID: store.getters.isSignedIn ? values.STR(store.state.i.id) : values.NULL, + USER_NAME: store.getters.isSignedIn ? values.STR(store.state.i.name) : values.NULL, + USER_USERNAME: store.getters.isSignedIn ? values.STR(store.state.i.username) : values.NULL, 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { - await vm.$root.dialog({ + 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 vm.$root.dialog({ + const confirm = await os.dialog({ type: type ? type.value : 'question', showCancelButton: true, title: title.value, @@ -28,7 +28,7 @@ export function createAiScriptEnv(vm, opts) { if (token) utils.assertString(token); apiRequests++; if (apiRequests > 16) return values.NULL; - const res = await vm.$root.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || 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]) => { @@ -42,45 +42,3 @@ export function createAiScriptEnv(vm, opts) { }), }; } - -// TODO: vue3に移行した折にはvmを渡す必要は無くなるはず -export function createPluginEnv(vm, opts) { - const config = new Map(); - for (const [k, v] of Object.entries(opts.plugin.config || {})) { - config.set(k, jsToVal(opts.plugin.configData[k] || v.default)); - } - - return { - ...createAiScriptEnv(vm, { ...opts, token: opts.plugin.token }), - //#region Deprecated - 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - //#endregion - 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { - vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler }); - }), - 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { - vm.$store.commit('registerNoteViewInterruptor', { pluginId: opts.plugin.id, handler }); - }), - 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { - vm.$store.commit('registerNotePostInterruptor', { pluginId: opts.plugin.id, handler }); - }), - 'Plugin:open_url': values.FN_NATIVE(([url]) => { - window.open(url.value, '_blank'); - }), - 'Plugin:config': values.OBJ(config), - }; -} diff --git a/src/client/scripts/autocomplete.ts b/src/client/scripts/autocomplete.ts new file mode 100644 index 0000000000..444f416156 --- /dev/null +++ b/src/client/scripts/autocomplete.ts @@ -0,0 +1,251 @@ +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>; + close: Function; + }; + 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 max = Math.max( + mentionIndex, + hashtagIndex, + emojiIndex); + + if (max == -1) { + this.close(); + return; + } + + const isMention = mentionIndex != -1; + const isHashtag = hashtagIndex != -1; + const isEmoji = emojiIndex != -1; + + 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 (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private async open(type: string, q: string) { + 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 MkAutocomplete = await import('@/components/autocomplete.vue'); + + const _x = ref(x); + const _y = ref(y); + const _q = ref(q); + + const { dispose } = popup(MkAutocomplete, { + 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); + }); + } + } +} diff --git a/src/client/scripts/extract-avg-color-from-blurhash.ts b/src/client/scripts/extract-avg-color-from-blurhash.ts new file mode 100644 index 0000000000..123ab7a06d --- /dev/null +++ b/src/client/scripts/extract-avg-color-from-blurhash.ts @@ -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; +} diff --git a/src/client/scripts/focus.ts b/src/client/scripts/focus.ts index a2a8516d36..0894877820 100644 --- a/src/client/scripts/focus.ts +++ b/src/client/scripts/focus.ts @@ -1,21 +1,25 @@ -export function focusPrev(el: Element | null, self = false) { +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(); + (el as HTMLElement).focus({ + preventScroll: !scroll + }); } else { focusPrev(el.previousElementSibling, true); } } } -export function focusNext(el: Element | null, self = false) { +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(); + (el as HTMLElement).focus({ + preventScroll: !scroll + }); } else { focusPrev(el.nextElementSibling, true); } diff --git a/src/client/scripts/gen-search-query.ts b/src/client/scripts/gen-search-query.ts index 2520da75df..670d915104 100644 --- a/src/client/scripts/gen-search-query.ts +++ b/src/client/scripts/gen-search-query.ts @@ -1,5 +1,5 @@ import parseAcct from '../../misc/acct/parse'; -import { host as localHost } from '../config'; +import { host as localHost } from '@/config'; export async function genSearchQuery(v: any, q: string) { let host: string; @@ -13,7 +13,7 @@ export async function genSearchQuery(v: any, q: string) { host = at; } } else { - const user = await v.$root.api('users/show', parseAcct(at)).catch(x => null); + const user = await v.os.api('users/show', parseAcct(at)).catch(x => null); if (user) { userId = user.id; } else { diff --git a/src/client/scripts/get-static-image-url.ts b/src/client/scripts/get-static-image-url.ts index eff76af256..e932eb6da5 100644 --- a/src/client/scripts/get-static-image-url.ts +++ b/src/client/scripts/get-static-image-url.ts @@ -1,4 +1,4 @@ -import { url as instanceUrl } from '../config'; +import { url as instanceUrl } from '@/config'; import * as url from '../../prelude/url'; export function getStaticImageUrl(baseUrl: string): string { diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts new file mode 100644 index 0000000000..63c3ae43b6 --- /dev/null +++ b/src/client/scripts/get-user-menu.ts @@ -0,0 +1,194 @@ +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { host } from '@/config'; +import getAcct from '../../misc/acct/render'; +import * as os from '@/os'; +import { store, userActions } from '@/store'; +import { router } from '@/router'; +import { defineAsyncComponent } from 'vue'; +import { popout } from './popout'; + +export function getUserMenu(user) { + async function pushList() { + const t = i18n.global.t('selectList'); // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await os.api('users/lists/list'); + if (lists.length === 0) { + os.dialog({ + type: 'error', + text: i18n.global.t('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.global.t('youHaveNoGroups') + }); + return; + } + const { canceled, result: groupId } = await os.dialog({ + type: null, + title: i18n.global.t('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.global.t('unblockConfirm') : i18n.global.t('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.global.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.global.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: user.id + }).then(() => { + user.isSuspended = !user.isSuspended; + }); + } + + 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: faAt, + text: i18n.global.t('copyUsername'), + action: () => { + copyToClipboard(`@${user.username}@${user.host || host}`); + } + }, { + icon: faEnvelope, + text: i18n.global.t('sendMessage'), + action: () => { + os.post({ specified: user }); + } + }, store.state.i.id != user.id ? { + icon: faComments, + text: i18n.global.t('startMessaging'), + action: () => { + const acct = getAcct(user); + switch (store.state.device.chatOpenBehavior) { + case 'window': { os.pageWindow('/my/messaging/' + acct, defineAsyncComponent(() => import('@/pages/messaging/messaging-room.vue')), { userAcct: acct }); break; } + case 'popout': { popout('/my/messaging'); break; } + default: { router.push('/my/messaging'); break; } + } + } + } : undefined, null, { + icon: faListUl, + text: i18n.global.t('addToList'), + action: pushList + }, store.state.i.id != user.id ? { + icon: faUsers, + text: i18n.global.t('inviteToGroup'), + action: inviteGroup + } : undefined] as any; + + if (store.getters.isSignedIn && store.state.i.id != user.id) { + menu = menu.concat([null, { + icon: user.isMuted ? faEye : faEyeSlash, + text: user.isMuted ? i18n.global.t('unmute') : i18n.global.t('mute'), + action: toggleMute + }, { + icon: faBan, + text: user.isBlocking ? i18n.global.t('unblock') : i18n.global.t('block'), + action: toggleBlock + }]); + + if (store.getters.isSignedIn && (store.state.i.isAdmin || store.state.i.isModerator)) { + menu = menu.concat([null, { + icon: faMicrophoneSlash, + text: user.isSilenced ? i18n.global.t('unsilence') : i18n.global.t('silence'), + action: toggleSilence + }, { + icon: faSnowflake, + text: user.isSuspended ? i18n.global.t('unsuspend') : i18n.global.t('suspend'), + action: toggleSuspend + }]); + } + } + + if (store.getters.isSignedIn && store.state.i.id === user.id) { + menu = menu.concat([null, { + icon: faPencilAlt, + text: i18n.global.t('editProfile'), + action: () => { + router.push('/settings/profile'); + } + }]); + } + + if (userActions.length > 0) { + menu = menu.concat([null, ...userActions.map(action => ({ + icon: faPlug, + text: action.title, + action: () => { + action.handler(user); + } + }))]); + } + + return menu; +} diff --git a/src/client/scripts/hotkey.ts b/src/client/scripts/hotkey.ts deleted file mode 100644 index 5f73aa58b9..0000000000 --- a/src/client/scripts/hotkey.ts +++ /dev/null @@ -1,116 +0,0 @@ -import keyCode from './keycode'; -import { concat } from '../../prelude/array'; - -type pattern = { - which: string[]; - ctrl?: boolean; - shift?: boolean; - alt?: boolean; -}; - -type action = { - patterns: pattern[]; - - callback: Function; - - allowRepeat: boolean; -}; - -const getKeyMap = 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 default { - install(Vue) { - Vue.directive('hotkey', { - bind(el, binding) { - el._hotkey_global = binding.modifiers.global === true; - - const actions = getKeyMap(binding.value); - - // flatten - const reservedKeys = concat(actions.map(a => a.patterns)); - - el._misskey_reservedKeys = reservedKeys; - - el._keyHandler = (e: KeyboardEvent) => { - const targetReservedKeys = document.activeElement ? ((document.activeElement as any)._misskey_reservedKeys || []) : []; - if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return; - if (document.activeElement && document.activeElement.attributes['contenteditable']) return; - - for (const action of actions) { - const matched = match(e, action.patterns); - - if (matched) { - if (!action.allowRepeat && e.repeat) return; - if (el._hotkey_global && match(e, targetReservedKeys)) return; - - e.preventDefault(); - e.stopPropagation(); - action.callback(e); - break; - } - } - }; - - if (el._hotkey_global) { - document.addEventListener('keydown', el._keyHandler); - } else { - el.addEventListener('keydown', el._keyHandler); - } - }, - - unbind(el) { - if (el._hotkey_global) { - document.removeEventListener('keydown', el._keyHandler); - } else { - el.removeEventListener('keydown', el._keyHandler); - } - } - }); - } -}; diff --git a/src/client/scripts/hpml/evaluator.ts b/src/client/scripts/hpml/evaluator.ts index a056884368..01a122c0e4 100644 --- a/src/client/scripts/hpml/evaluator.ts +++ b/src/client/scripts/hpml/evaluator.ts @@ -1,11 +1,12 @@ import autobind from 'autobind-decorator'; import * as seedrandom from 'seedrandom'; import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.'; -import { version } from '../../config'; +import { version } from '@/config'; import { AiScript, utils, values } from '@syuilo/aiscript'; import { createAiScriptEnv } from '../aiscript/api'; import { collectPageVars } from '../collect-page-vars'; import { initLib } from './lib'; +import * as os from '@/os'; type Fn = { slots: string[]; @@ -30,19 +31,19 @@ export class Hpml { enableAiScript: boolean; }; - constructor(vm: any, page: Hpml['page'], opts: Hpml['opts']) { + 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 = new AiScript({ ...createAiScriptEnv(vm, { + this.aiscript = new AiScript({ ...createAiScriptEnv({ storageKey: 'pages:' + this.page.id }), ...initLib(this)}, { in: (q) => { return new Promise(ok => { - vm.$root.dialog({ + os.dialog({ title: q, input: {} }).then(({ canceled, result: a }) => { diff --git a/src/client/scripts/loading.ts b/src/client/scripts/loading.ts index 70a3a4c85e..4b0a560e34 100644 --- a/src/client/scripts/loading.ts +++ b/src/client/scripts/loading.ts @@ -1,21 +1,11 @@ -import * as NProgress from 'nprogress'; -NProgress.configure({ - trickleSpeed: 500, - showSpinner: false -}); - -const root = document.getElementsByTagName('html')[0]; - export default { start: () => { - root.classList.add('progress'); - NProgress.start(); + // TODO }, done: () => { - root.classList.remove('progress'); - NProgress.done(); + // TODO }, set: val => { - NProgress.set(val); + // TODO } }; diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index 538615afa1..3d9668f108 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -1,8 +1,12 @@ +import { markRaw } from 'vue'; +import * as os from '@/os'; import { onScrollTop, isTopVisible } from './scroll'; const SECOND_FETCH_LIMIT = 30; export default (opts) => ({ + emits: ['queue'], + data() { return { items: [], @@ -14,13 +18,6 @@ export default (opts) => ({ more: false, backed: false, // 遡り中か否か isBackTop: false, - ilObserver: new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) - && !this.moreFetching - && !this.fetching - && this.fetchMore() - ), - loadMoreElement: null as Element, }; }, @@ -35,41 +32,33 @@ export default (opts) => ({ }, watch: { - pagination() { - this.init(); + pagination: { + handler() { + this.init(); + }, + deep: true }, - queue() { - this.$emit('queue', this.queue.length); + 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(); - - this.$on('hook:activated', () => { - this.isBackTop = false; - }); - - this.$on('hook:deactivated', () => { - this.isBackTop = window.scrollY === 0; - }); }, - mounted() { - this.$nextTick(() => { - if (this.$refs.loadMore) { - this.loadMoreElement = this.$refs.loadMore instanceof Element ? this.$refs.loadMore : this.$refs.loadMore.$el; - if (this.$store.state.device.enableInfiniteScroll) this.ilObserver.observe(this.loadMoreElement); - this.loadMoreElement.addEventListener('click', this.fetchMore); - } - }); + activated() { + this.isBackTop = false; }, - beforeDestroy() { - this.ilObserver.disconnect(); - if (this.$refs.loadMore) this.loadMoreElement.removeEventListener('click', this.fetchMore); + deactivated() { + this.isBackTop = window.scrollY === 0; }, methods: { @@ -78,19 +67,30 @@ export default (opts) => ({ 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 this.$root.api(endpoint, { + await os.api(endpoint, { ...params, limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1, }).then(items => { for (const item of items) { - Object.freeze(item); + markRaw(item); } if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) { items.pop(); @@ -111,13 +111,13 @@ export default (opts) => ({ }, async fetchMore() { - if (!this.more || this.moreFetching || this.items.length === 0) return; + 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 this.$root.api(endpoint, { + await os.api(endpoint, { ...params, limit: SECOND_FETCH_LIMIT + 1, ...(this.pagination.offsetMode ? { @@ -129,7 +129,7 @@ export default (opts) => ({ }), }).then(items => { for (const item of items) { - Object.freeze(item); + markRaw(item); } if (items.length > SECOND_FETCH_LIMIT) { items.pop(); @@ -172,9 +172,5 @@ export default (opts) => ({ append(item) { this.items.push(item); }, - - remove(find) { - this.items = this.items.filter(x => !find(x)); - }, } }); diff --git a/src/client/scripts/please-login.ts b/src/client/scripts/please-login.ts index ebd7dd82ab..a221665295 100644 --- a/src/client/scripts/please-login.ts +++ b/src/client/scripts/please-login.ts @@ -1,10 +1,14 @@ -export default ($root: any) => { - if ($root.$store.getters.isSignedIn) return; +import { i18n } from '@/i18n'; +import { dialog } from '@/os'; +import { store } from '@/store'; - $root.dialog({ - title: $root.$t('signinRequired'), +export function pleaseLogin() { + if (store.getters.isSignedIn) return; + + dialog({ + title: i18n.global.t('signinRequired'), text: null }); throw new Error('signin required'); -}; +} diff --git a/src/client/scripts/popout.ts b/src/client/scripts/popout.ts new file mode 100644 index 0000000000..f3611390c6 --- /dev/null +++ b/src/client/scripts/popout.ts @@ -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 = 450; + 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}`); + } +} diff --git a/src/client/scripts/search.ts b/src/client/scripts/search.ts index 16057dfd34..45cc691fe4 100644 --- a/src/client/scripts/search.ts +++ b/src/client/scripts/search.ts @@ -1,15 +1,29 @@ import { faHistory } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { router } from '@/router'; + +export async function search(q?: string | null | undefined) { + if (q == null) { + const { canceled, result: query } = await os.dialog({ + title: i18n.global.t('search'), + input: true + }); + + if (canceled || query == null || query === '') return; + + q = query; + } -export async function search(v: any, q: string) { q = q.trim(); if (q.startsWith('@') && !q.includes(' ')) { - v.$router.push(`/${q}`); + router.push(`/${q}`); return; } if (q.startsWith('#')) { - v.$router.push(`/tags/${encodeURIComponent(q.substr(1))}`); + router.push(`/tags/${encodeURIComponent(q.substr(1))}`); return; } @@ -26,7 +40,7 @@ export async function search(v: any, q: string) { } v.$root.$emit('warp', date); - v.$root.dialog({ + os.dialog({ icon: faHistory, iconOnly: true, autoClose: true }); @@ -34,31 +48,31 @@ export async function search(v: any, q: string) { } if (q.startsWith('https://')) { - const dialog = v.$root.dialog({ + const dialog = os.dialog({ type: 'waiting', - text: v.$t('fetchingAsApObject') + '...', + text: i18n.global.t('fetchingAsApObject') + '...', showOkButton: false, showCancelButton: false, cancelableByBgClick: false }); try { - const res = await v.$root.api('ap/show', { + const res = await os.api('ap/show', { uri: q }); - dialog.close(); + dialog.cancel(); if (res.type === 'User') { - v.$router.push(`/@${res.object.username}@${res.object.host}`); + router.push(`/@${res.object.username}@${res.object.host}`); } else if (res.type === 'Note') { - v.$router.push(`/notes/${res.object.id}`); + router.push(`/notes/${res.object.id}`); } } catch (e) { - dialog.close(); + dialog.cancel(); // TODO: Show error } return; } - v.$router.push(`/search?q=${encodeURIComponent(q)}`); + router.push(`/search?q=${encodeURIComponent(q)}`); } diff --git a/src/client/scripts/select-drive-file.ts b/src/client/scripts/select-drive-file.ts deleted file mode 100644 index 3a4ac70007..0000000000 --- a/src/client/scripts/select-drive-file.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function selectDriveFile($root: any, multiple) { - return new Promise((res, rej) => { - import('../components/drive-window.vue').then(m => m.default).then(dialog => { - const w = $root.new(dialog, { - type: 'file', - multiple - }); - w.$once('selected', files => { - res(multiple ? files : files[0]); - }); - }); - }); -} diff --git a/src/client/scripts/select-drive-folder.ts b/src/client/scripts/select-drive-folder.ts deleted file mode 100644 index 313d552e3a..0000000000 --- a/src/client/scripts/select-drive-folder.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function selectDriveFolder($root: any, multiple) { - return new Promise((res, rej) => { - import('../components/drive-window.vue').then(m => m.default).then(dialog => { - const w = $root.new(dialog, { - type: 'folder', - multiple - }); - w.$once('selected', folders => { - res(multiple ? folders : (folders.length === 0 ? null : folders[0])); - }); - }); - }); -} diff --git a/src/client/scripts/select-file.ts b/src/client/scripts/select-file.ts index 462bdae9c0..80f9d25a2e 100644 --- a/src/client/scripts/select-file.ts +++ b/src/client/scripts/select-file.ts @@ -1,45 +1,23 @@ -import { faUpload, faCloud } from '@fortawesome/free-solid-svg-icons'; -import { selectDriveFile } from './select-drive-file'; -import { apiUrl } from '../config'; +import { faUpload, faCloud, faLink } from '@fortawesome/free-solid-svg-icons'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; -export function selectFile(component: any, src: any, label: string | null, multiple = false) { +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 dialog = component.$root.dialog({ - type: 'waiting', - text: component.$t('uploading') + '...', - showOkButton: false, - showCancelButton: false, - cancelableByBgClick: false - }); - - const promises = Array.from(input.files).map(file => new Promise((ok, err) => { - const data = new FormData(); - data.append('file', file); - data.append('i', component.$store.state.i.token); - - fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: data - }) - .then(response => response.json()) - .then(ok) - .catch(err); - })); + const promises = Array.from(input.files).map(file => os.upload(file)); Promise.all(promises).then(driveFiles => { res(multiple ? driveFiles : driveFiles[0]); }).catch(e => { - component.$root.dialog({ + os.dialog({ type: 'error', text: e }); - }).finally(() => { - dialog.close(); }); // 一応廃棄 @@ -54,34 +32,57 @@ export function selectFile(component: any, src: any, label: string | null, multi }; const chooseFileFromDrive = () => { - selectDriveFile(component.$root, multiple).then(files => { + os.selectDriveFile(multiple).then(files => { res(files); }); }; - // TODO const chooseFileFromUrl = () => { + os.dialog({ + title: i18n.global.t('uploadFromUrl'), + input: { + placeholder: i18n.global.t('uploadFromUrlDescription') + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + const connection = os.stream.useSharedConnection('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, + marker + }); + os.dialog({ + title: i18n.global.t('uploadFromUrlRequested'), + text: i18n.global.t('uploadFromUrlMayTakeTime') + }); + }); }; - component.$root.menu({ - items: [label ? { - text: label, - type: 'label' - } : undefined, { - text: component.$t('upload'), - icon: faUpload, - action: chooseFileFromPc - }, { - text: component.$t('fromDrive'), - icon: faCloud, - action: chooseFileFromDrive - }, /*{ - text: component.$t('fromUrl'), - icon: faLink, - action: chooseFileFromUrl - }*/], - source: src - }); + os.modalMenu([label ? { + text: label, + type: 'label' + } : undefined, { + text: i18n.global.t('upload'), + icon: faUpload, + action: chooseFileFromPc + }, { + text: i18n.global.t('fromDrive'), + icon: faCloud, + action: chooseFileFromDrive + }, { + text: i18n.global.t('fromUrl'), + icon: faLink, + action: chooseFileFromUrl + }], src); }); } diff --git a/src/client/scripts/set-i18n-contexts.ts b/src/client/scripts/set-i18n-contexts.ts index 872153e0bd..6014957361 100644 --- a/src/client/scripts/set-i18n-contexts.ts +++ b/src/client/scripts/set-i18n-contexts.ts @@ -1,8 +1,7 @@ -import VueI18n from 'vue-i18n'; import { clientDb, clear, bulkSet } from '../db'; import { deepEntries, delimitEntry } from 'deep-entries'; -export function setI18nContexts(lang: string, version: string, i18n: VueI18n, cleardb = false) { +export function setI18nContexts(lang: string, version: string, cleardb = false) { return Promise.all([ cleardb ? clear(clientDb.i18n) : Promise.resolve(), fetch(`/assets/locales/${lang}.${version}.json`) @@ -11,7 +10,6 @@ export function setI18nContexts(lang: string, version: string, i18n: VueI18n, cl .then(locale => { const flatLocaleEntries = deepEntries(locale, delimitEntry) as [string, string][]; bulkSet(flatLocaleEntries, clientDb.i18n); - i18n.locale = lang; - i18n.setLocaleMessage(lang, Object.fromEntries(flatLocaleEntries)); + return Object.fromEntries(flatLocaleEntries); }); } diff --git a/src/client/scripts/stream.ts b/src/client/scripts/stream.ts index defb22af8e..789bf94320 100644 --- a/src/client/scripts/stream.ts +++ b/src/client/scripts/stream.ts @@ -1,8 +1,7 @@ import autobind from 'autobind-decorator'; import { EventEmitter } from 'eventemitter3'; import ReconnectingWebsocket from 'reconnecting-websocket'; -import { wsUrl } from '../config'; -import MiOS from '../mios'; +import { wsUrl } from '@/config'; import { query as urlQuery } from '../../prelude/url'; /** @@ -10,18 +9,13 @@ import { query as urlQuery } from '../../prelude/url'; */ export default class Stream extends EventEmitter { private stream: ReconnectingWebsocket; - public state: 'initializing' | 'reconnecting' | 'connected'; + public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing'; private sharedConnectionPools: Pool[] = []; private sharedConnections: SharedConnection[] = []; private nonSharedConnections: NonSharedConnection[] = []; - constructor(os: MiOS) { - super(); - - this.state = 'initializing'; - - const user = os.store.state.i; - + @autobind + public init(user): void { const query = urlQuery({ i: user?.token, _t: Date.now(), diff --git a/src/client/scripts/theme-editor.ts b/src/client/scripts/theme-editor.ts index e0c3bc25bc..3d69d2836a 100644 --- a/src/client/scripts/theme-editor.ts +++ b/src/client/scripts/theme-editor.ts @@ -5,11 +5,12 @@ 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 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 | Default; +export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default; export type ThemeViewModel = [ string, ThemeValue ][]; @@ -31,17 +32,23 @@ export const fromThemeString = (str?: string) : ThemeValue => { 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) => { +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}`; } }; diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts index 30eaf77e01..476a41ace5 100644 --- a/src/client/scripts/theme.ts +++ b/src/client/scripts/theme.ts @@ -101,7 +101,7 @@ function compile(theme: Theme): Record<string, string> { for (const [k, v] of Object.entries(theme.props)) { if (k.startsWith('$')) continue; // ignore const - props[k] = genValue(getColor(v)); + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); } return props; |