parent
1838511766
commit
ecb3c43520
9 changed files with 420 additions and 94 deletions
171
packages/client/src/components/cropper-dialog.vue
Normal file
171
packages/client/src/components/cropper-dialog.vue
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue