summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkModal.vue
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
commit9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch)
treece5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/components/MkModal.vue
parentwip: retention for dashboard (diff)
downloadsharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz
sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2
sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/components/MkModal.vue')
-rw-r--r--packages/frontend/src/components/MkModal.vue406
1 files changed, 406 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
new file mode 100644
index 0000000000..2305a02794
--- /dev/null
+++ b/packages/frontend/src/components/MkModal.vue
@@ -0,0 +1,406 @@
+<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="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">
+ <slot :max-height="maxHeight" :type="type"></slot>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, watch, provide } from 'vue';
+import * as os from '@/os';
+import { isTouchUsing } from '@/scripts/touch';
+import { defaultStore } from '@/store';
+import { deviceKind } from '@/scripts/device-kind';
+
+function getFixedContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const position = window.getComputedStyle(el).getPropertyValue('position');
+ if (position === 'fixed') {
+ return el;
+ } else {
+ return getFixedContainer(el.parentElement);
+ }
+}
+
+type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer';
+
+const props = withDefaults(defineProps<{
+ manualShowing?: boolean | null;
+ anchor?: { x: string; y: string; };
+ src?: HTMLElement;
+ preferType?: ModalTypes | 'auto';
+ zPriority?: 'low' | 'middle' | 'high';
+ noOverlap?: boolean;
+ transparentBg?: boolean;
+}>(), {
+ manualShowing: null,
+ src: null,
+ anchor: () => ({ x: 'center', y: 'bottom' }),
+ preferType: 'auto',
+ zPriority: 'low',
+ noOverlap: true,
+ transparentBg: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'opening'): void;
+ (ev: 'opened'): void;
+ (ev: 'click'): void;
+ (ev: 'esc'): void;
+ (ev: 'close'): void;
+ (ev: 'closed'): void;
+}>();
+
+provide('modal', true);
+
+let maxHeight = $ref<number>();
+let fixed = $ref(false);
+let transformOrigin = $ref('center');
+let showing = $ref(true);
+let content = $ref<HTMLElement>();
+const zIndex = os.claimZIndex(props.zPriority);
+const type = $computed(() => {
+ if (props.preferType === 'auto') {
+ if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
+ return 'drawer';
+ } else {
+ return props.src != null ? 'popup' : 'dialog';
+ }
+ } else {
+ return props.preferType!;
+ }
+});
+
+let contentClicking = false;
+
+const close = () => {
+ // eslint-disable-next-line vue/no-mutating-props
+ if (props.src) props.src.style.pointerEvents = 'auto';
+ showing = false;
+ emit('close');
+};
+
+const onBgClick = () => {
+ if (contentClicking) return;
+ emit('click');
+};
+
+if (type === 'drawer') {
+ maxHeight = window.innerHeight / 1.5;
+}
+
+const keymap = {
+ 'esc': () => emit('esc'),
+};
+
+const MARGIN = 16;
+
+const align = () => {
+ if (props.src == null) return;
+ if (type === 'drawer') return;
+ if (type === 'dialog') return;
+
+ if (content == null) return;
+
+ const srcRect = props.src.getBoundingClientRect();
+
+ const width = content!.offsetWidth;
+ const height = content!.offsetHeight;
+
+ let left;
+ let top;
+
+ const x = srcRect.left + (fixed ? 0 : window.pageXOffset);
+ const y = srcRect.top + (fixed ? 0 : window.pageYOffset);
+
+ if (props.anchor.x === 'center') {
+ left = x + (props.src.offsetWidth / 2) - (width / 2);
+ } else if (props.anchor.x === 'left') {
+ // TODO
+ } else if (props.anchor.x === 'right') {
+ left = x + props.src.offsetWidth;
+ }
+
+ if (props.anchor.y === 'center') {
+ top = (y - (height / 2));
+ } else if (props.anchor.y === 'top') {
+ // TODO
+ } else if (props.anchor.y === 'bottom') {
+ top = y + props.src.offsetHeight;
+ }
+
+ if (fixed) {
+ // 画面から横にはみ出る場合
+ if (left + width > window.innerWidth) {
+ left = window.innerWidth - width;
+ }
+
+ const underSpace = (window.innerHeight - MARGIN) - top;
+ const upperSpace = (srcRect.top - MARGIN);
+
+ // 画面から縦にはみ出る場合
+ if (top + height > (window.innerHeight - MARGIN)) {
+ if (props.noOverlap && props.anchor.x === 'center') {
+ if (underSpace >= (upperSpace / 3)) {
+ maxHeight = underSpace;
+ } else {
+ maxHeight = upperSpace;
+ top = (upperSpace + MARGIN) - height;
+ }
+ } else {
+ top = (window.innerHeight - MARGIN) - height;
+ }
+ } else {
+ maxHeight = underSpace;
+ }
+ } else {
+ // 画面から横にはみ出る場合
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset - 1;
+ }
+
+ const underSpace = (window.innerHeight - MARGIN) - (top - window.pageYOffset);
+ const upperSpace = (srcRect.top - MARGIN);
+
+ // 画面から縦にはみ出る場合
+ if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) {
+ if (props.noOverlap && props.anchor.x === 'center') {
+ if (underSpace >= (upperSpace / 3)) {
+ maxHeight = underSpace;
+ } else {
+ maxHeight = upperSpace;
+ top = window.pageYOffset + ((upperSpace + MARGIN) - height);
+ }
+ } else {
+ top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1;
+ }
+ } else {
+ maxHeight = underSpace;
+ }
+ }
+
+ if (top < 0) {
+ top = MARGIN;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ let transformOriginX = 'center';
+ let transformOriginY = 'center';
+
+ if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) {
+ transformOriginY = 'top';
+ } else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) {
+ transformOriginY = 'bottom';
+ }
+
+ if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) {
+ transformOriginX = 'left';
+ } else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) {
+ transformOriginX = 'right';
+ }
+
+ transformOrigin = `${transformOriginX} ${transformOriginY}`;
+
+ content.style.left = left + 'px';
+ content.style.top = top + 'px';
+};
+
+const onOpened = () => {
+ emit('opened');
+
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const el = content!.children[0];
+ el.addEventListener('mousedown', ev => {
+ contentClicking = true;
+ window.addEventListener('mouseup', ev => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ window.setTimeout(() => {
+ contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+};
+
+onMounted(() => {
+ watch(() => props.src, async () => {
+ if (props.src) {
+ // eslint-disable-next-line vue/no-mutating-props
+ props.src.style.pointerEvents = 'none';
+ }
+ fixed = (type === 'drawer') || (getFixedContainer(props.src) != null);
+
+ await nextTick();
+
+ align();
+ }, { immediate: true });
+
+ nextTick(() => {
+ new ResizeObserver((entries, observer) => {
+ align();
+ }).observe(content!);
+ });
+});
+
+defineExpose({
+ close,
+});
+</script>
+
+<style lang="scss" scoped>
+.modal-enter-active, .modal-leave-active {
+ > .bg {
+ transition: opacity 0.2s !important;
+ }
+
+ > .content {
+ transform-origin: var(--transformOrigin);
+ transition: opacity 0.2s, transform 0.2s !important;
+ }
+}
+.modal-enter-from, .modal-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ opacity: 0;
+ transform-origin: var(--transformOrigin);
+ transform: scale(0.9);
+ }
+}
+
+.modal-popup-enter-active, .modal-popup-leave-active {
+ > .bg {
+ transition: opacity 0.2s !important;
+ }
+
+ > .content {
+ transform-origin: var(--transformOrigin);
+ transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
+ }
+}
+.modal-popup-enter-from, .modal-popup-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ opacity: 0;
+ transform-origin: var(--transformOrigin);
+ transform: scale(0.9);
+ }
+}
+
+.modal-drawer-enter-active {
+ > .bg {
+ transition: opacity 0.2s !important;
+ }
+
+ > .content {
+ transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
+ }
+}
+.modal-drawer-leave-active {
+ > .bg {
+ transition: opacity 0.2s !important;
+ }
+
+ > .content {
+ transition: transform 0.2s cubic-bezier(0,.5,0,1) !important;
+ }
+}
+.modal-drawer-enter-from, .modal-drawer-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ transform: translateY(100%);
+ }
+}
+
+.qzhlnise {
+ > .bg {
+ &.transparent {
+ background: transparent;
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ }
+ }
+
+ &.dialog {
+ > .content {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ padding: 32px;
+ // TODO: mask-imageはiOSだとやたら重い。なんとかしたい
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+ overflow: auto;
+ display: flex;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+ }
+
+ > ::v-deep(*) {
+ margin: auto;
+ }
+
+ &.top {
+ > ::v-deep(*) {
+ margin-top: 0;
+ }
+ }
+ }
+ }
+
+ &.popup {
+ > .content {
+ position: absolute;
+
+ &.fixed {
+ position: fixed;
+ }
+ }
+ }
+
+ &.drawer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: clip;
+
+ > .content {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+
+ > ::v-deep(*) {
+ margin: auto;
+ }
+ }
+ }
+
+}
+</style>