summaryrefslogtreecommitdiff
path: root/packages/client/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-06-11 15:45:44 +0900
committerGitHub <noreply@github.com>2022-06-11 15:45:44 +0900
commitecb3c43520e1f47447a86f4cac8b25aef039f229 (patch)
tree0d5bb902d078d47f1b8f0b040700c307dec32fb2 /packages/client/src
parentupdate cypress (diff)
downloadmisskey-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.vue171
-rw-r--r--packages/client/src/components/ui/modal-window.vue128
-rw-r--r--packages/client/src/components/ui/modal.vue9
-rw-r--r--packages/client/src/os.ts45
-rw-r--r--packages/client/src/pages/settings/profile.vue32
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;