feat: image cropping (#8808)

* wip

* wip

* wip
This commit is contained in:
syuilo 2022-06-11 15:45:44 +09:00 committed by GitHub
parent 1838511766
commit ecb3c43520
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 420 additions and 94 deletions

View file

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

View file

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

View file

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

View file

@ -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();

View file

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