enhance: メニュー関連をComposition API化、switchアイテム追加 (#8215)

* メニューをComposition API化、switchアイテム追加
クライアントサイド画像圧縮の準備

* メニュー型定義を分離 (TypeScriptの型支援が効かないので)

* disabled

* make keepOriginal to follow setting value

* fix

* fix

* Fix

* clean up
This commit is contained in:
tamaina 2022-01-30 14:11:52 +09:00 committed by GitHub
parent aa64ff6c94
commit 55b3ae22ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 199 additions and 235 deletions

View File

@ -235,6 +235,8 @@ resetAreYouSure: "リセットしますか?"
saved: "保存しました" saved: "保存しました"
messaging: "チャット" messaging: "チャット"
upload: "アップロード" upload: "アップロード"
keepOriginalUploading: "オリジナル画像を保持"
keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。"
fromDrive: "ドライブから" fromDrive: "ドライブから"
fromUrl: "URLから" fromUrl: "URLから"
uploadFromUrl: "URLアップロード" uploadFromUrl: "URLアップロード"

View File

@ -20,45 +20,33 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, ref, toRefs } from 'vue'; import { toRefs, Ref } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import Ripple from '@/components/ripple.vue'; import Ripple from '@/components/ripple.vue';
export default defineComponent({ const props = defineProps<{
props: { modelValue: boolean | Ref<boolean>;
modelValue: { disabled?: boolean;
type: Boolean, }>();
default: false
},
disabled: {
type: Boolean,
default: false
}
},
setup(props, context) { const emit = defineEmits<{
const button = ref<HTMLElement>(); (e: 'update:modelValue', v: boolean): void;
}>();
let button = $ref<HTMLElement>();
const checked = toRefs(props).modelValue; const checked = toRefs(props).modelValue;
const toggle = () => { const toggle = () => {
if (props.disabled) return; if (props.disabled) return;
context.emit('update:modelValue', !checked.value); emit('update:modelValue', !checked.value);
if (!checked.value) { if (!checked.value) {
const rect = button.value.getBoundingClientRect(); const rect = button.getBoundingClientRect();
const x = rect.left + (button.value.offsetWidth / 2); const x = rect.left + (button.offsetWidth / 2);
const y = rect.top + (button.value.offsetHeight / 2); const y = rect.top + (button.offsetHeight / 2);
os.popup(Ripple, { x, y, particle: false }, {}, 'end'); os.popup(Ripple, { x, y, particle: false }, {}, 'end');
} }
}; };
return {
button,
checked,
toggle,
};
},
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,53 +1,37 @@
<template> <template>
<transition :name="$store.state.animation ? 'fade' : ''" appear> <transition :name="$store.state.animation ? 'fade' : ''" appear>
<div class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}"> <div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/> <MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/>
</div> </div>
</transition> </transition>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onMounted, onBeforeUnmount } from 'vue';
import contains from '@/scripts/contains'; import contains from '@/scripts/contains';
import MkMenu from './menu.vue'; import MkMenu from './menu.vue';
import { MenuItem } from './types/menu.vue';
import * as os from '@/os'; import * as os from '@/os';
export default defineComponent({ const props = defineProps<{
components: { items: MenuItem[];
MkMenu, ev: MouseEvent;
}, }>();
props: {
items: {
type: Array,
required: true
},
ev: {
required: true
},
viaKeyboard: {
type: Boolean,
required: false
},
},
emits: ['closed'],
data() {
return {
zIndex: os.claimZIndex('high'),
};
},
computed: {
keymap(): any {
return {
'esc': () => this.$emit('closed'),
};
},
},
mounted() {
let left = this.ev.pageX + 1; // + 1
let top = this.ev.pageY + 1; // + 1
const width = this.$el.offsetWidth; const emit = defineEmits<{
const height = this.$el.offsetHeight; (e: 'closed'): void;
}>();
let rootEl = $ref<HTMLDivElement>();
let zIndex = $ref<number>(os.claimZIndex('high'));
onMounted(() => {
let left = props.ev.pageX + 1; // + 1
let top = props.ev.pageY + 1; // + 1
const width = rootEl.offsetWidth;
const height = rootEl.offsetHeight;
if (left + width - window.pageXOffset > window.innerWidth) { if (left + width - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - width + window.pageXOffset; left = window.innerWidth - width + window.pageXOffset;
@ -65,24 +49,23 @@ export default defineComponent({
left = 0; left = 0;
} }
this.$el.style.top = top + 'px'; rootEl.style.top = `${top}px`;
this.$el.style.left = left + 'px'; rootEl.style.left = `${left}px`;
for (const el of Array.from(document.querySelectorAll('body *'))) { for (const el of Array.from(document.querySelectorAll('body *'))) {
el.addEventListener('mousedown', this.onMousedown); el.addEventListener('mousedown', onMousedown);
}
},
beforeUnmount() {
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', this.onMousedown);
}
},
methods: {
onMousedown(e) {
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed');
},
} }
}); });
onBeforeUnmount(() => {
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', onMousedown);
}
});
function onMousedown(e: Event) {
if (!contains(rootEl, e.target) && (rootEl != e.target)) emit('closed');
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,8 +1,8 @@
<template> <template>
<div ref="items" v-hotkey="keymap" <div ref="itemsEl" v-hotkey="keymap"
class="rrevdjwt" class="rrevdjwt"
:class="{ center: align === 'center', asDrawer }" :class="{ center: align === 'center', asDrawer }"
:style="{ width: (width && !asDrawer) ? width + 'px' : null, maxHeight: maxHeight ? maxHeight + 'px' : null }" :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
@contextmenu.self="e => e.preventDefault()" @contextmenu.self="e => e.preventDefault()"
> >
<template v-for="(item, i) in items2"> <template v-for="(item, i) in items2">
@ -28,6 +28,9 @@
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button> </button>
<span v-else-if="item.type === 'switch'" :tabindex="i" class="item">
<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
</span>
<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> <button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i> <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
@ -41,114 +44,78 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, ref, unref } from 'vue'; import { nextTick, onMounted, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus'; import { focusPrev, focusNext } from '@/scripts/focus';
import contains from '@/scripts/contains'; import FormSwitch from '@/components/form/switch.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
export default defineComponent({ const props = defineProps<{
props: { items: MenuItem[];
items: { viaKeyboard?: boolean;
type: Array, asDrawer?: boolean;
required: true align?: 'center' | string;
}, width?: number;
viaKeyboard: { maxHeight?: number;
type: Boolean, }>();
required: false
},
asDrawer: {
type: Boolean,
required: false
},
align: {
type: String,
requried: false
},
width: {
type: Number,
required: false
},
maxHeight: {
type: Number,
required: false
},
},
emits: ['close'],
data() {
return {
items2: [],
};
},
computed: {
keymap(): any {
return {
'up|k|shift+tab': this.focusUp,
'down|j|tab': this.focusDown,
'esc': this.close,
};
},
},
watch: {
items: {
handler() {
const items = ref(unref(this.items).filter(item => item !== undefined));
for (let i = 0; i < items.value.length; i++) { const emit = defineEmits<{
const item = items.value[i]; (e: 'close'): void;
}>();
if (item && item.then) { // if item is Promise let itemsEl = $ref<HTMLDivElement>();
items.value[i] = { type: 'pending' };
let items2: InnerMenuItem[] = $ref([]);
let keymap = $computed(() => ({
'up|k|shift+tab': focusUp,
'down|j|tab': focusDown,
'esc': close,
}));
watch(() => props.items, () => {
const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && 'then' in item) { // if item is Promise
items[i] = { type: 'pending' };
item.then(actualItem => { item.then(actualItem => {
items.value[i] = actualItem; items2[i] = actualItem;
}); });
} }
} }
this.items2 = items; items2 = items as InnerMenuItem[];
}, }, {
immediate: true immediate: true,
} });
},
mounted() { onMounted(() => {
if (this.viaKeyboard) { if (props.viaKeyboard) {
this.$nextTick(() => { nextTick(() => {
focusNext(this.$refs.items.children[0], true, false); focusNext(itemsEl.children[0], true, false);
}); });
} }
});
if (this.contextmenuEvent) { function clicked(fn: MenuAction, ev: MouseEvent) {
this.$el.style.top = this.contextmenuEvent.pageY + 'px';
this.$el.style.left = this.contextmenuEvent.pageX + 'px';
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.addEventListener('mousedown', this.onMousedown);
}
}
},
beforeUnmount() {
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', this.onMousedown);
}
},
methods: {
clicked(fn, ev) {
fn(ev); fn(ev);
this.close(); close();
}, }
close() {
this.$emit('close'); function close() {
}, emit('close');
focusUp() { }
focusPrev(document.activeElement);
}, function focusUp() {
focusDown() { focusPrev(document.activeElement);
focusNext(document.activeElement); }
},
onMousedown(e) { function focusDown() {
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); focusNext(document.activeElement);
},
} }
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,44 +1,28 @@
<template> <template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="$refs.modal.close()" @closed="$emit('closed')"> <MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="$refs.modal.close()"/> <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/>
</MkModal> </MkModal>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import MkModal from './modal.vue'; import MkModal from './modal.vue';
import MkMenu from './menu.vue'; import MkMenu from './menu.vue';
import { MenuItem } from '@/types/menu';
export default defineComponent({ defineProps<{
components: { items: MenuItem[];
MkModal, align?: 'center' | string;
MkMenu, width?: number;
}, viaKeyboard?: boolean;
src?: any;
}>();
props: { const emit = defineEmits<{
items: { (e: 'closed'): void;
type: Array, }>();
required: true
},
align: {
type: String,
required: false
},
width: {
type: Number,
required: false
},
viaKeyboard: {
type: Boolean,
required: false
},
src: {
required: false
},
},
emits: ['close', 'closed'], let modal = $ref<InstanceType<typeof MkModal>>();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -7,8 +7,10 @@ import * as Misskey from 'misskey-js';
import { apiUrl, url } from '@/config'; import { apiUrl, url } from '@/config';
import MkPostFormDialog from '@/components/post-form-dialog.vue'; import MkPostFormDialog from '@/components/post-form-dialog.vue';
import MkWaitingDialog from '@/components/waiting-dialog.vue'; import MkWaitingDialog from '@/components/waiting-dialog.vue';
import { MenuItem } from '@/types/menu';
import { resolve } from '@/router'; import { resolve } from '@/router';
import { $i } from '@/account'; import { $i } from '@/account';
import { defaultStore } from '@/store';
export const pendingApiRequestsCount = ref(0); export const pendingApiRequestsCount = ref(0);
@ -470,7 +472,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
}); });
} }
export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: { export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement, options?: {
align?: string; align?: string;
width?: number; width?: number;
viaKeyboard?: boolean; viaKeyboard?: boolean;
@ -494,7 +496,7 @@ export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?
}); });
} }
export function contextMenu(items: any[], ev: MouseEvent) { export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) {
ev.preventDefault(); ev.preventDefault();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let dispose; let dispose;
@ -541,7 +543,7 @@ export const uploads = ref<{
img: string; img: string;
}[]>([]); }[]>([]);
export function upload(file: File, folder?: any, name?: string): Promise<Misskey.entities.DriveFile> { export function upload(file: File, folder?: any, name?: string, keepOriginal: boolean = defaultStore.state.keepOriginalUploading): Promise<Misskey.entities.DriveFile> {
if (folder && typeof folder == 'object') folder = folder.id; if (folder && typeof folder == 'object') folder = folder.id;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -559,6 +561,8 @@ export function upload(file: File, folder?: any, name?: string): Promise<Misskey
uploads.value.push(ctx); uploads.value.push(ctx);
console.log(keepOriginal);
const data = new FormData(); const data = new FormData();
data.append('i', $i.token); data.append('i', $i.token);
data.append('force', 'true'); data.append('force', 'true');

View File

@ -28,6 +28,7 @@
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
<template #suffixIcon><i class="fas fa-folder-open"></i></template> <template #suffixIcon><i class="fas fa-folder-open"></i></template>
</FormLink> </FormLink>
<FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ $ts.keepOriginalUploading }}<template #caption>{{ $ts.keepOriginalUploadingDescription }}</template></FormSwitch>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@ -36,18 +37,21 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import * as tinycolor from 'tinycolor2'; import * as tinycolor from 'tinycolor2';
import FormLink from '@/components/form/link.vue'; import FormLink from '@/components/form/link.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import MkKeyValue from '@/components/key-value.vue'; import MkKeyValue from '@/components/key-value.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import * as os from '@/os'; import * as os from '@/os';
import bytes from '@/filters/bytes'; import bytes from '@/filters/bytes';
import * as symbols from '@/symbols'; import * as symbols from '@/symbols';
import { defaultStore } from '@/store';
// TODO: render chart // TODO: render chart
export default defineComponent({ export default defineComponent({
components: { components: {
FormLink, FormLink,
FormSwitch,
FormSection, FormSection,
MkKeyValue, MkKeyValue,
FormSplit, FormSplit,
@ -79,7 +83,8 @@ export default defineComponent({
l: 0.5 l: 0.5
}) })
}; };
} },
keepOriginalUploading: defaultStore.makeGetterSetter('keepOriginalUploading'),
}, },
async created() { async created() {

View File

@ -1,3 +1,4 @@
import { ref } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
@ -6,12 +7,14 @@ import { DriveFile } from 'misskey-js/built/entities';
function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
const chooseFileFromPc = () => { const chooseFileFromPc = () => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.multiple = multiple; input.multiple = multiple;
input.onchange = () => { input.onchange = () => {
const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder)); const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
Promise.all(promises).then(driveFiles => { Promise.all(promises).then(driveFiles => {
res(multiple ? driveFiles : driveFiles[0]); res(multiple ? driveFiles : driveFiles[0]);
@ -74,6 +77,10 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv
text: label, text: label,
type: 'label' type: 'label'
} : undefined, { } : undefined, {
type: 'switch',
text: i18n.ts.keepOriginalUploading,
ref: keepOriginal
}, {
text: i18n.ts.upload, text: i18n.ts.upload,
icon: 'fas fa-upload', icon: 'fas fa-upload',
action: chooseFileFromPc action: chooseFileFromPc

View File

@ -43,6 +43,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: 'yyyy-MM-dd HH-mm-ss [{{number}}]' default: 'yyyy-MM-dd HH-mm-ss [{{number}}]'
}, },
keepOriginalUploading: {
where: 'account',
default: false
},
memo: { memo: {
where: 'account', where: 'account',
default: null default: null

View File

@ -0,0 +1,20 @@
import * as Misskey from 'misskey-js';
import { Ref } from 'vue';
export type MenuAction = (ev: MouseEvent) => void;
export type MenuDivider = null;
export type MenuNull = undefined;
export type MenuLabel = { type: 'label', text: string };
export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean };
export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction };
export type MenuPending = { type: 'pending' };
type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton;
type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton>;
export type MenuItem = OuterMenuItem | OuterPromiseMenuItem;
export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton;