diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-06-11 15:45:44 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-06-11 15:45:44 +0900 |
| commit | ecb3c43520e1f47447a86f4cac8b25aef039f229 (patch) | |
| tree | 0d5bb902d078d47f1b8f0b040700c307dec32fb2 /packages/client/src | |
| parent | update cypress (diff) | |
| download | misskey-ecb3c43520e1f47447a86f4cac8b25aef039f229.tar.gz misskey-ecb3c43520e1f47447a86f4cac8b25aef039f229.tar.bz2 misskey-ecb3c43520e1f47447a86f4cac8b25aef039f229.zip | |
feat: image cropping (#8808)
* wip
* wip
* wip
Diffstat (limited to 'packages/client/src')
| -rw-r--r-- | packages/client/src/components/cropper-dialog.vue | 171 | ||||
| -rw-r--r-- | packages/client/src/components/ui/modal-window.vue | 128 | ||||
| -rw-r--r-- | packages/client/src/components/ui/modal.vue | 9 | ||||
| -rw-r--r-- | packages/client/src/os.ts | 45 | ||||
| -rw-r--r-- | packages/client/src/pages/settings/profile.vue | 32 |
5 files changed, 300 insertions, 85 deletions
diff --git a/packages/client/src/components/cropper-dialog.vue b/packages/client/src/components/cropper-dialog.vue new file mode 100644 index 0000000000..24ae4e87ae --- /dev/null +++ b/packages/client/src/components/cropper-dialog.vue @@ -0,0 +1,171 @@ +<template> +<XModalWindow + ref="dialogEl" + :width="800" + :height="500" + :scroll="false" + :with-ok-button="true" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.cropImage }}</template> + <template #default="{ width, height }"> + <div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> + <Transition name="fade"> + <div v-if="loading" class="loading"> + <MkLoading/> + </div> + </Transition> + <div class="container"> + <img ref="imgEl" :src="file.url" style="display: none;" @load="onImageLoad"> + </div> + </div> + </template> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import Cropper from 'cropperjs'; +import tinycolor from 'tinycolor2'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import { apiUrl } from '@/config'; + +const emit = defineEmits<{ + (ev: 'ok', cropped: misskey.entities.DriveFile): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const props = defineProps<{ + file: misskey.entities.DriveFile; + aspectRatio: number; +}>(); + +let dialogEl = $ref<InstanceType<typeof XModalWindow>>(); +let imgEl = $ref<HTMLImageElement>(); +let cropper: Cropper | null = null; +let loading = $ref(true); + +const ok = async () => { + const promise = new Promise<misskey.entities.DriveFile>(async (res) => { + const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); + croppedCanvas.toBlob(blob => { + const formData = new FormData(); + formData.append('file', blob); + formData.append('i', $i.token); + if (defaultStore.state.uploadFolder) { + formData.append('folderId', defaultStore.state.uploadFolder); + } + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: formData, + }) + .then(response => response.json()) + .then(f => { + res(f); + }); + }); + }); + + os.promiseDialog(promise); + + const f = await promise; + + emit('ok', f); + dialogEl.close(); +}; + +const cancel = () => { + emit('cancel'); + dialogEl.close(); +}; + +const onImageLoad = () => { + loading = false; + + if (cropper) { + cropper.getCropperImage()!.$center('contain'); + cropper.getCropperSelection()!.$center(); + } +}; + +onMounted(() => { + cropper = new Cropper(imgEl, { + }); + + const computedStyle = getComputedStyle(document.documentElement); + + const selection = cropper.getCropperSelection()!; + selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + selection.aspectRatio = props.aspectRatio; + selection.initialAspectRatio = props.aspectRatio; + selection.outlined = true; + + window.setTimeout(() => { + cropper.getCropperImage()!.$center('contain'); + selection.$center(); + }, 100); + + // モーダルオープンアニメーションが終わったあとで再度調整 + window.setTimeout(() => { + cropper.getCropperImage()!.$center('contain'); + selection.$center(); + }, 500); +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.5s ease 0.5s; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.mk-cropper-dialog { + display: flex; + flex-direction: column; + width: var(--vw); + height: var(--vh); + position: relative; + + > .loading { + position: absolute; + z-index: 10; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + background: rgba(0, 0, 0, 0.5); + } + + > .container { + flex: 1; + width: 100%; + height: 100%; + + > ::v-deep(cropper-canvas) { + width: 100%; + height: 100%; + + > cropper-selection > cropper-handle[action="move"] { + background: transparent; + } + } + } +} +</style> diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index 6de29c83fa..d2b2ccff7a 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -1,7 +1,7 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" @click="$emit('click')" @closed="$emit('closed')"> - <div class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> - <div class="header"> +<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> + <div ref="rootEl" class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> + <div ref="headerEl" class="header"> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <span class="title"> <slot name="header"></slot> @@ -11,82 +11,82 @@ </div> <div v-if="padding" class="body"> <div class="_section"> - <slot></slot> + <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> <div v-else class="body"> - <slot></slot> + <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> </MkModal> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted } from 'vue'; import MkModal from './modal.vue'; -export default defineComponent({ - components: { - MkModal - }, - props: { - withOkButton: { - type: Boolean, - required: false, - default: false - }, - okButtonDisabled: { - type: Boolean, - required: false, - default: false - }, - padding: { - type: Boolean, - required: false, - default: false - }, - width: { - type: Number, - required: false, - default: 400 - }, - height: { - type: Number, - required: false, - default: null - }, - canClose: { - type: Boolean, - required: false, - default: true, - }, - scroll: { - type: Boolean, - required: false, - default: true, - }, - }, +const props = withDefaults(defineProps<{ + withOkButton: boolean; + okButtonDisabled: boolean; + padding: boolean; + width: number; + height: number | null; + scroll: boolean; +}>(), { + withOkButton: false, + okButtonDisabled: false, + padding: false, + width: 400, + height: null, + scroll: true, +}); - emits: ['click', 'close', 'closed', 'ok'], +const emit = defineEmits<{ + (event: 'click'): void; + (event: 'close'): void; + (event: 'closed'): void; + (event: 'ok'): void; +}>(); - data() { - return { - }; - }, +let modal = $ref<InstanceType<typeof MkModal>>(); +let rootEl = $ref<HTMLElement>(); +let headerEl = $ref<HTMLElement>(); +let bodyWidth = $ref(0); +let bodyHeight = $ref(0); - methods: { - close() { - this.$refs.modal.close(); - }, +const close = () => { + modal.close(); +}; - onKeydown(evt) { - if (evt.which === 27) { // Esc - evt.preventDefault(); - evt.stopPropagation(); - this.close(); - } - }, +const onBgClick = () => { + emit('click'); +}; + +const onKeydown = (evt) => { + if (evt.which === 27) { // Esc + evt.preventDefault(); + evt.stopPropagation(); + close(); } +}; + +const ro = new ResizeObserver((entries, observer) => { + bodyWidth = rootEl.offsetWidth; + bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; +}); + +onMounted(() => { + bodyWidth = rootEl.offsetWidth; + bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; + ro.observe(rootEl); +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +defineExpose({ + close, }); </script> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue index 010262da2f..d6a29ec4b7 100644 --- a/packages/client/src/components/ui/modal.vue +++ b/packages/client/src/components/ui/modal.vue @@ -1,5 +1,5 @@ <template> -<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="childRendered"> +<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"> <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick"> @@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'opening'): void; + (ev: 'opened'): void; (ev: 'click'): void; (ev: 'esc'): void; (ev: 'close'): void; @@ -212,7 +213,9 @@ const align = () => { popover.style.top = top + 'px'; }; -const childRendered = () => { +const onOpened = () => { + emit('opened'); + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する const el = content.value!.children[0]; el.addEventListener('mousedown', ev => { @@ -237,7 +240,7 @@ onMounted(() => { await nextTick(); align(); - }, { immediate: true, }); + }, { immediate: true }); nextTick(() => { const popover = content.value; diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index 4f19fadf19..14860465fa 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -34,7 +34,7 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s method: 'POST', body: JSON.stringify(data), credentials: 'omit', - cache: 'no-cache' + cache: 'no-cache', }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); @@ -142,7 +142,7 @@ export async function popup(component: Component, props: Record<string, any>, ev props, events: disposeEvent ? { ...events, - [disposeEvent]: dispose + [disposeEvent]: dispose, } : events, id, }; @@ -174,7 +174,7 @@ export function modalPageWindow(path: string) { export function toast(message: string) { popup(defineAsyncComponent(() => import('@/components/toast.vue')), { - message + message, }, {}, 'closed'); } @@ -226,7 +226,7 @@ export function inputText(props: { type: props.type, placeholder: props.placeholder, default: props.default, - } + }, }, { done: result => { resolve(result ? result : { canceled: true }); @@ -251,7 +251,7 @@ export function inputNumber(props: { type: 'number', placeholder: props.placeholder, default: props.default, - } + }, }, { done: result => { resolve(result ? result : { canceled: true }); @@ -276,7 +276,7 @@ export function inputDate(props: { type: 'date', placeholder: props.placeholder, default: props.default, - } + }, }, { done: result => { resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true }); @@ -313,7 +313,7 @@ export function select<C = any>(props: { items: props.items, groupedItems: props.groupedItems, default: props.default, - } + }, }, { done: result => { resolve(result ? result : { canceled: true }); @@ -330,7 +330,7 @@ export function success() { }, 1000); popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { success: true, - showing: showing + showing: showing, }, { done: () => resolve(), }, 'closed'); @@ -342,7 +342,7 @@ export function waiting() { const showing = ref(true); popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { success: false, - showing: showing + showing: showing, }, { done: () => resolve(), }, 'closed'); @@ -373,7 +373,7 @@ export async function selectDriveFile(multiple: boolean) { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), { type: 'file', - multiple + multiple, }, { done: files => { if (files) { @@ -388,7 +388,7 @@ export async function selectDriveFolder(multiple: boolean) { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/drive-select-dialog.vue')), { type: 'folder', - multiple + multiple, }, { done: folders => { if (folders) { @@ -403,7 +403,7 @@ export async function pickEmoji(src: HTMLElement | null, opts) { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/emoji-picker-dialog.vue')), { src, - ...opts + ...opts, }, { done: emoji => { resolve(emoji); @@ -412,6 +412,21 @@ export async function pickEmoji(src: HTMLElement | null, opts) { }); } +export async function cropImage(image: Misskey.entities.DriveFile, options: { + aspectRatio: number; +}): Promise<Misskey.entities.DriveFile> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/cropper-dialog.vue')), { + file: image, + aspectRatio: options.aspectRatio, + }, { + ok: x => { + resolve(x); + }, + }, 'closed'); + }); +} + type AwaitType<T> = T extends Promise<infer U> ? U : T extends (...args: any[]) => Promise<infer V> ? V : @@ -453,7 +468,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/emoji-picker-window.vue')), { src, - ...opts + ...opts, }, { chosen: emoji => { insertTextAtCursor(activeTextarea, emoji); @@ -462,7 +477,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: openingEmojiPicker!.dispose(); openingEmojiPicker = null; observer.disconnect(); - } + }, }); } @@ -478,7 +493,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement src, width: options?.width, align: options?.align, - viaKeyboard: options?.viaKeyboard + viaKeyboard: options?.viaKeyboard, }, { closed: () => { resolve(); diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue index e991d725b6..b64dc93cc7 100644 --- a/packages/client/src/pages/settings/profile.vue +++ b/packages/client/src/pages/settings/profile.vue @@ -62,7 +62,7 @@ </template> <script lang="ts" setup> -import { defineComponent, reactive, watch } from 'vue'; +import { reactive, watch } from 'vue'; import MkButton from '@/components/ui/button.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; @@ -132,8 +132,21 @@ function save() { function changeAvatar(ev) { selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + const i = await os.apiWithDialog('i/update', { - avatarId: file.id, + avatarId: originalOrCropped.id, }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; @@ -142,8 +155,21 @@ function changeAvatar(ev) { function changeBanner(ev) { selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 2, + }); + } + const i = await os.apiWithDialog('i/update', { - bannerId: file.id, + bannerId: originalOrCropped.id, }); $i.bannerId = i.bannerId; $i.bannerUrl = i.bannerUrl; |