262
packages/client/src/components/ui/button.vue
Normal file
262
packages/client/src/components/ui/button.vue
Normal file
@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<button v-if="!link" class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full }"
|
||||
:type="type"
|
||||
@click="$emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
<MkA v-else class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full }"
|
||||
:to="to"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
primary: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
gradate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
wait: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
full: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
mounted() {
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(() => {
|
||||
this.$el.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onMousedown(e: MouseEvent) {
|
||||
function distance(p, q) {
|
||||
return Math.hypot(p.x - q.x, p.y - q.y);
|
||||
}
|
||||
|
||||
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) {
|
||||
const origin = {x: circleCenterX, y: circleCenterY};
|
||||
const dist1 = distance({x: 0, y: 0}, origin);
|
||||
const dist2 = distance({x: boxW, y: 0}, origin);
|
||||
const dist3 = distance({x: 0, y: boxH}, origin);
|
||||
const dist4 = distance({x: boxW, y: boxH }, origin);
|
||||
return Math.max(dist1, dist2, dist3, dist4) * 2;
|
||||
}
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px';
|
||||
ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px';
|
||||
|
||||
this.$refs.ripples.appendChild(ripple);
|
||||
|
||||
const circleCenterX = e.clientX - rect.left;
|
||||
const circleCenterY = e.clientY - rect.top;
|
||||
|
||||
const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY);
|
||||
|
||||
setTimeout(() => {
|
||||
ripple.style.transform = 'scale(' + (scale / 2) + ')';
|
||||
}, 1);
|
||||
setTimeout(() => {
|
||||
ripple.style.transition = 'all 1s ease';
|
||||
ripple.style.opacity = '0';
|
||||
}, 1000);
|
||||
setTimeout(() => {
|
||||
if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bghgjjyj {
|
||||
position: relative;
|
||||
z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
|
||||
display: block;
|
||||
min-width: 100px;
|
||||
width: max-content;
|
||||
padding: 8px 14px;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
line-height: 22px;
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
background: var(--buttonBg);
|
||||
border-radius: 4px;
|
||||
overflow: clip;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.rounded {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
background: var(--accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--X8);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--X8);
|
||||
}
|
||||
}
|
||||
|
||||
&.gradate {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff2a2a;
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
background: #ff2a2a;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #ff4242;
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: #d42e2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
> .ripples {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep(div) {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: all 0.5s cubic-bezier(0,.5,0,1);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary > .ripples ::v-deep(div) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
> .content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
262
packages/client/src/components/ui/container.vue
Normal file
262
packages/client/src/components/ui/container.vue
Normal file
@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }">
|
||||
<header v-if="showHeader" ref="header">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="sub">
|
||||
<slot name="func"></slot>
|
||||
<button class="_button" v-if="foldable" @click="() => showBody = !showBody">
|
||||
<template v-if="showBody"><i class="fas fa-angle-up"></i></template>
|
||||
<template v-else><i class="fas fa-angle-down"></i></template>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<transition name="container-toggle"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody" class="content" :class="{ omitted }" ref="content">
|
||||
<slot></slot>
|
||||
<button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }">
|
||||
<span>{{ $ts.showMore }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
thin: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
naked: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
foldable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
maxHeight: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBody: this.expanded,
|
||||
omitted: null,
|
||||
ignoreOmit: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch('showBody', showBody => {
|
||||
const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
|
||||
this.$el.style.minHeight = `${headerHeight}px`;
|
||||
if (showBody) {
|
||||
this.$el.style.flexBasis = `auto`;
|
||||
} else {
|
||||
this.$el.style.flexBasis = `${headerHeight}px`;
|
||||
}
|
||||
}, {
|
||||
immediate: true
|
||||
});
|
||||
|
||||
this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
|
||||
|
||||
const calcOmit = () => {
|
||||
if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
|
||||
const height = this.$refs.content.offsetHeight;
|
||||
this.omitted = height > this.maxHeight;
|
||||
};
|
||||
|
||||
calcOmit();
|
||||
new ResizeObserver((entries, observer) => {
|
||||
calcOmit();
|
||||
}).observe(this.$refs.content);
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
if (!this.foldable) return;
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-toggle-enter-active, .container-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition: opacity 0.5s, height 0.5s !important;
|
||||
}
|
||||
.container-toggle-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.container-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ukygtjoj {
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.scrollable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
position: sticky;
|
||||
top: var(--stickyTop, 0px);
|
||||
left: 0;
|
||||
color: var(--panelHeaderFg);
|
||||
background: var(--panelHeaderBg);
|
||||
border-bottom: solid 0.5px var(--panelHeaderDivider);
|
||||
z-index: 2;
|
||||
line-height: 1.4em;
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
|
||||
> ::v-deep(i) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .sub {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
|
||||
> ::v-deep(button) {
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
--stickyTop: 0px;
|
||||
|
||||
&.omitted {
|
||||
position: relative;
|
||||
max-height: var(--maxHeight);
|
||||
overflow: hidden;
|
||||
|
||||
> .fade {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
background: var(--panel);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> span {
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_380px, &.thin {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._forceContainerFull_ .ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._forceContainerFull_.ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
97
packages/client/src/components/ui/context-menu.vue
Normal file
97
packages/client/src/components/ui/context-menu.vue
Normal file
@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<transition :name="$store.state.animation ? 'fade' : ''" appear>
|
||||
<div class="nvlagfpb" @contextmenu.prevent.stop="() => {}">
|
||||
<MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import MkMenu from './menu.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkMenu,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
ev: {
|
||||
required: true
|
||||
},
|
||||
viaKeyboard: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
},
|
||||
emits: ['closed'],
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
'esc': () => this.$emit('closed'),
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
|
||||
const width = this.$el.offsetWidth;
|
||||
const height = this.$el.offsetHeight;
|
||||
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset;
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
this.$el.style.top = top + 'px';
|
||||
this.$el.style.left = left + 'px';
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.addEventListener('mousedown', this.onMousedown);
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.removeEventListener('mousedown', this.onMousedown);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onMousedown(e) {
|
||||
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed');
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nvlagfpb {
|
||||
position: absolute;
|
||||
z-index: 65535;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
156
packages/client/src/components/ui/folder.vue
Normal file
156
packages/client/src/components/ui/folder.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="ssazuxis" v-size="{ max: [500] }">
|
||||
<header @click="showBody = !showBody" class="_button" :style="{ background: bg }">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="divider"></div>
|
||||
<button class="_button">
|
||||
<template v-if="showBody"><i class="fas fa-angle-up"></i></template>
|
||||
<template v-else><i class="fas fa-angle-down"></i></template>
|
||||
</button>
|
||||
</header>
|
||||
<transition name="folder-toggle"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import * as tinycolor from 'tinycolor2';
|
||||
|
||||
const localStoragePrefix = 'ui:folder:';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
persistKey: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bg: null,
|
||||
showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showBody() {
|
||||
if (this.persistKey) {
|
||||
localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
function getParentBg(el: Element | null): string {
|
||||
if (el == null || el.tagName === 'BODY') return 'var(--bg)';
|
||||
const bg = el.style.background || el.style.backgroundColor;
|
||||
if (bg) {
|
||||
return bg;
|
||||
} else {
|
||||
return getParentBg(el.parentElement);
|
||||
}
|
||||
}
|
||||
const rawBg = getParentBg(this.$el);
|
||||
const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
|
||||
bg.setAlpha(0.85);
|
||||
this.bg = bg.toRgbString();
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-toggle-enter-active, .folder-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition: opacity 0.5s, height 0.5s !important;
|
||||
}
|
||||
.folder-toggle-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.folder-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ssazuxis {
|
||||
position: relative;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
top: var(--stickyTop, 0px);
|
||||
padding: var(--x-padding);
|
||||
-webkit-backdrop-filter: var(--blur, blur(8px));
|
||||
backdrop-filter: var(--blur, blur(20px));
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px 12px 0;
|
||||
|
||||
> i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
flex: 1;
|
||||
margin: auto;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
> button {
|
||||
padding: 12px 0 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
16
packages/client/src/components/ui/hr.vue
Normal file
16
packages/client/src/components/ui/hr.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="evrzpitu"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';import * as os from '@/os';
|
||||
|
||||
export default defineComponent({});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.evrzpitu
|
||||
margin 16px 0
|
||||
border-bottom solid var(--lineWidth) var(--faceDivider)
|
||||
|
||||
</style>
|
45
packages/client/src/components/ui/info.vue
Normal file
45
packages/client/src/components/ui/info.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="fpezltsf" :class="{ warn }">
|
||||
<i v-if="warn" class="fas fa-exclamation-triangle"></i>
|
||||
<i v-else class="fas fa-info-circle"></i>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
warn: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fpezltsf {
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoBg);
|
||||
color: var(--infoFg);
|
||||
border-radius: var(--radius);
|
||||
|
||||
&.warn {
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--infoWarnFg);
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
278
packages/client/src/components/ui/menu.vue
Normal file
278
packages/client/src/components/ui/menu.vue
Normal file
@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="rrevdjwt" :class="{ center: align === 'center' }"
|
||||
:style="{ width: width ? width + 'px' : null }"
|
||||
ref="items"
|
||||
@contextmenu.self="e => e.preventDefault()"
|
||||
v-hotkey="keymap"
|
||||
>
|
||||
<template v-for="(item, i) in _items">
|
||||
<div v-if="item === null" class="divider"></div>
|
||||
<span v-else-if="item.type === 'label'" class="label item">
|
||||
<span>{{ item.text }}</span>
|
||||
</span>
|
||||
<span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
|
||||
<span><MkEllipsis/></span>
|
||||
</span>
|
||||
<MkA v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item">
|
||||
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
|
||||
</MkA>
|
||||
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item">
|
||||
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
|
||||
</a>
|
||||
<button v-else-if="item.type === 'user'" @click="clicked(item.action, $event)" :tabindex="i" class="_button item">
|
||||
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
|
||||
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
|
||||
</button>
|
||||
<button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active">
|
||||
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
|
||||
<span>{{ item.text }}</span>
|
||||
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
|
||||
</button>
|
||||
</template>
|
||||
<span v-if="_items.length === 0" class="none item">
|
||||
<span>{{ $ts.none }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, unref } from 'vue';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus';
|
||||
import contains from '@/scripts/contains';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
viaKeyboard: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
requried: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
data() {
|
||||
return {
|
||||
_items: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
'up|k|shift+tab': this.focusUp,
|
||||
'down|j|tab': this.focusDown,
|
||||
'esc': this.close,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
items: {
|
||||
handler() {
|
||||
const items = ref(unref(this.items).filter(item => item !== undefined));
|
||||
|
||||
for (let i = 0; i < items.value.length; i++) {
|
||||
const item = items.value[i];
|
||||
|
||||
if (item && item.then) { // if item is Promise
|
||||
items.value[i] = { type: 'pending' };
|
||||
item.then(actualItem => {
|
||||
items.value[i] = actualItem;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this._items = items;
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.viaKeyboard) {
|
||||
this.$nextTick(() => {
|
||||
focusNext(this.$refs.items.children[0], true, false);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.contextmenuEvent) {
|
||||
this.$el.style.top = this.contextmenuEvent.pageY + 'px';
|
||||
this.$el.style.left = this.contextmenuEvent.pageX + 'px';
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.addEventListener('mousedown', this.onMousedown);
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.removeEventListener('mousedown', this.onMousedown);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clicked(fn, ev) {
|
||||
fn(ev);
|
||||
this.close();
|
||||
},
|
||||
close() {
|
||||
this.$emit('close');
|
||||
},
|
||||
focusUp() {
|
||||
focusPrev(document.activeElement);
|
||||
},
|
||||
focusDown() {
|
||||
focusNext(document.activeElement);
|
||||
},
|
||||
onMousedown(e) {
|
||||
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rrevdjwt {
|
||||
padding: 8px 0;
|
||||
min-width: 200px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
|
||||
&.center {
|
||||
> .item {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
> .item {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 8px 18px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9em;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: calc(100% - 16px);
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff2a2a;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
|
||||
&:before {
|
||||
background: #ff4242;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: #fff;
|
||||
|
||||
&:before {
|
||||
background: #d42e2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--fgOnAccent);
|
||||
opacity: 1;
|
||||
|
||||
&:before {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:before {
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active):focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus) inset;
|
||||
}
|
||||
|
||||
&.label {
|
||||
pointer-events: none;
|
||||
font-size: 0.7em;
|
||||
padding-bottom: 4px;
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&.pending {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.none {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 5px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
margin-right: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
> .indicator {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 13px;
|
||||
color: var(--indicator);
|
||||
font-size: 12px;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
margin: 8px 0;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
}
|
||||
</style>
|
148
packages/client/src/components/ui/modal-window.vue
Normal file
148
packages/client/src/components/ui/modal-window.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
|
||||
<div class="ebkgoccj _window _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }">
|
||||
<div class="header">
|
||||
<button class="_button" v-if="withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button>
|
||||
<span class="title">
|
||||
<slot name="header"></slot>
|
||||
</span>
|
||||
<button class="_button" v-if="!withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button>
|
||||
<button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><i class="fas fa-check"></i></button>
|
||||
</div>
|
||||
<div class="body" v-if="padding">
|
||||
<div class="_section">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } 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,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['click', 'close', 'closed', 'ok'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.$refs.modal.close();
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
if (e.which === 27) { // Esc
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ebkgoccj {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
contain: content;
|
||||
|
||||
--root-margin: 24px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
|
||||
> .header {
|
||||
$height: 58px;
|
||||
$height-narrow: 42px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0px 1px var(--divider);
|
||||
|
||||
> button {
|
||||
height: $height;
|
||||
width: $height;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
height: $height-narrow;
|
||||
width: $height-narrow;
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
flex: 1;
|
||||
line-height: $height;
|
||||
padding-left: 32px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
line-height: $height-narrow;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
> button + .title {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
292
packages/client/src/components/ui/modal.vue
Normal file
292
packages/client/src/components/ui/modal.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
|
||||
<div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
|
||||
<div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
|
||||
<div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
provide: {
|
||||
modal: true
|
||||
},
|
||||
props: {
|
||||
manualShowing: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
srcCenter: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
src: {
|
||||
required: false,
|
||||
},
|
||||
position: {
|
||||
required: false
|
||||
},
|
||||
front: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
emits: ['opening', 'click', 'esc', 'close', 'closed'],
|
||||
data() {
|
||||
return {
|
||||
showing: true,
|
||||
fixed: false,
|
||||
transformOrigin: 'center',
|
||||
contentClicking: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
keymap(): any {
|
||||
return {
|
||||
'esc': () => this.$emit('esc'),
|
||||
};
|
||||
},
|
||||
popup(): boolean {
|
||||
return this.src != null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$watch('src', () => {
|
||||
this.fixed = getFixedContainer(this.src) != null;
|
||||
this.$nextTick(() => {
|
||||
this.align();
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
this.$nextTick(() => {
|
||||
const popover = this.$refs.content as any;
|
||||
new ResizeObserver((entries, observer) => {
|
||||
this.align();
|
||||
}).observe(popover);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
align() {
|
||||
if (!this.popup) return;
|
||||
|
||||
const popover = this.$refs.content as any;
|
||||
|
||||
if (popover == null) return;
|
||||
|
||||
const rect = this.src.getBoundingClientRect();
|
||||
|
||||
const width = popover.offsetWidth;
|
||||
const height = popover.offsetHeight;
|
||||
|
||||
let left;
|
||||
let top;
|
||||
|
||||
if (this.srcCenter) {
|
||||
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
|
||||
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2);
|
||||
left = (x - (width / 2));
|
||||
top = (y - (height / 2));
|
||||
} else {
|
||||
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
|
||||
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight;
|
||||
left = (x - (width / 2));
|
||||
top = y;
|
||||
}
|
||||
|
||||
if (this.fixed) {
|
||||
if (left + width > window.innerWidth) {
|
||||
left = window.innerWidth - width;
|
||||
}
|
||||
|
||||
if (top + height > window.innerHeight) {
|
||||
top = window.innerHeight - height;
|
||||
}
|
||||
} else {
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset - 1;
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
|
||||
this.transformOrigin = 'center top';
|
||||
} else {
|
||||
this.transformOrigin = 'center';
|
||||
}
|
||||
|
||||
popover.style.left = left + 'px';
|
||||
popover.style.top = top + 'px';
|
||||
},
|
||||
|
||||
childRendered() {
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const content = this.$refs.content.children[0];
|
||||
content.addEventListener('mousedown', e => {
|
||||
this.contentClicking = true;
|
||||
window.addEventListener('mouseup', e => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
setTimeout(() => {
|
||||
this.contentClicking = false;
|
||||
}, 100);
|
||||
}, { passive: true, once: true });
|
||||
}, { passive: true });
|
||||
},
|
||||
|
||||
close() {
|
||||
this.showing = false;
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
onBgClick() {
|
||||
if (this.contentClicking) return;
|
||||
this.$emit('click');
|
||||
},
|
||||
|
||||
onClosed() {
|
||||
this.$emit('closed');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.modal-popup-enter-active, .modal-popup-leave-active,
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
> .content {
|
||||
transform-origin: var(--transformOrigin);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-enter-active, .modal-leave-active {
|
||||
> .bg {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transition: opacity 0.3s, transform 0.3s !important;
|
||||
}
|
||||
}
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-popup-enter-active, .modal-popup-leave-active {
|
||||
> .bg {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
}
|
||||
.modal-popup-enter-from, .modal-popup-leave-to {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.qzhlnise {
|
||||
> .bg {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
> .content:not(.popup) {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .content.popup {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
|
||||
&.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
&.front {
|
||||
> .bg {
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
> .content:not(.popup) {
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
> .content.popup {
|
||||
z-index: 20000;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
69
packages/client/src/components/ui/pagination.vue
Normal file
69
packages/client/src/components/ui/pagination.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<transition name="fade" mode="out-in">
|
||||
<MkLoading v-if="fetching"/>
|
||||
|
||||
<MkError v-else-if="error" @retry="init()"/>
|
||||
|
||||
<div class="empty" v-else-if="empty" key="_empty_">
|
||||
<slot name="empty"></slot>
|
||||
</div>
|
||||
|
||||
<div v-else class="cxiknjgy">
|
||||
<slot :items="items"></slot>
|
||||
<div class="more _gap" v-show="more" key="_more_">
|
||||
<MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
|
||||
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
|
||||
<template v-if="moreFetching"><MkLoading inline/></template>
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import MkButton from './button.vue';
|
||||
import paging from '@/scripts/paging';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkButton
|
||||
},
|
||||
|
||||
mixins: [
|
||||
paging({}),
|
||||
],
|
||||
|
||||
props: {
|
||||
pagination: {
|
||||
required: true
|
||||
},
|
||||
|
||||
disableAutoLoad: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.125s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cxiknjgy {
|
||||
> .more > .button {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 48px;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
42
packages/client/src/components/ui/popup-menu.vue
Normal file
42
packages/client/src/components/ui/popup-menu.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<MkPopup ref="popup" :src="src" @closed="$emit('closed')">
|
||||
<MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/>
|
||||
</MkPopup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import MkPopup from './popup.vue';
|
||||
import MkMenu from './menu.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MkPopup,
|
||||
MkMenu,
|
||||
},
|
||||
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
viaKeyboard: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
src: {
|
||||
required: false
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['close', 'closed'],
|
||||
});
|
||||
</script>
|
213
packages/client/src/components/ui/popup.vue
Normal file
213
packages/client/src/components/ui/popup.vue
Normal file
@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
|
||||
<div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
manualShowing: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
srcCenter: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
src: {
|
||||
type: Object as PropType<HTMLElement>,
|
||||
required: false,
|
||||
},
|
||||
position: {
|
||||
required: false
|
||||
},
|
||||
front: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['opening', 'click', 'esc', 'close', 'closed'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
showing: true,
|
||||
fixed: false,
|
||||
transformOrigin: 'center',
|
||||
contentClicking: false,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$watch('src', () => {
|
||||
if (this.src) {
|
||||
this.src.style.pointerEvents = 'none';
|
||||
}
|
||||
this.fixed = getFixedContainer(this.src) != null;
|
||||
this.$nextTick(() => {
|
||||
this.align();
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
this.$nextTick(() => {
|
||||
const popover = this.$refs.content as any;
|
||||
new ResizeObserver((entries, observer) => {
|
||||
this.align();
|
||||
}).observe(popover);
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', this.onDocumentClick, { passive: true });
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('mousedown', this.onDocumentClick);
|
||||
},
|
||||
|
||||
methods: {
|
||||
align() {
|
||||
if (this.src == null) return;
|
||||
|
||||
const popover = this.$refs.content as any;
|
||||
|
||||
if (popover == null) return;
|
||||
|
||||
const rect = this.src.getBoundingClientRect();
|
||||
|
||||
const width = popover.offsetWidth;
|
||||
const height = popover.offsetHeight;
|
||||
|
||||
let left;
|
||||
let top;
|
||||
|
||||
if (this.srcCenter) {
|
||||
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
|
||||
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2);
|
||||
left = (x - (width / 2));
|
||||
top = (y - (height / 2));
|
||||
} else {
|
||||
const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
|
||||
const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight;
|
||||
left = (x - (width / 2));
|
||||
top = y;
|
||||
}
|
||||
|
||||
if (this.fixed) {
|
||||
if (left + width > window.innerWidth) {
|
||||
left = window.innerWidth - width;
|
||||
}
|
||||
|
||||
if (top + height > window.innerHeight) {
|
||||
top = window.innerHeight - height;
|
||||
}
|
||||
} else {
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset - 1;
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
|
||||
this.transformOrigin = 'center top';
|
||||
} else {
|
||||
this.transformOrigin = 'center';
|
||||
}
|
||||
|
||||
popover.style.left = left + 'px';
|
||||
popover.style.top = top + 'px';
|
||||
},
|
||||
|
||||
childRendered() {
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const content = this.$refs.content.children[0];
|
||||
content.addEventListener('mousedown', e => {
|
||||
this.contentClicking = true;
|
||||
window.addEventListener('mouseup', e => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
setTimeout(() => {
|
||||
this.contentClicking = false;
|
||||
}, 100);
|
||||
}, { passive: true, once: true });
|
||||
}, { passive: true });
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.src) this.src.style.pointerEvents = 'auto';
|
||||
this.showing = false;
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
onClosed() {
|
||||
this.$emit('closed');
|
||||
},
|
||||
|
||||
onDocumentClick(ev) {
|
||||
const flyoutElement = this.$refs.content;
|
||||
let targetElement = ev.target;
|
||||
do {
|
||||
if (targetElement === flyoutElement) {
|
||||
return;
|
||||
}
|
||||
targetElement = targetElement.parentNode;
|
||||
} while (targetElement);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-menu-enter-active {
|
||||
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;
|
||||
}
|
||||
.popup-menu-leave-active {
|
||||
transform-origin: var(--transformOrigin);
|
||||
transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), transform 0.2s cubic-bezier(0.4, 0, 1, 1) !important;
|
||||
}
|
||||
.popup-menu-enter-from, .popup-menu-leave-to {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.ccczpooj {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
|
||||
&.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
&.front {
|
||||
z-index: 20000;
|
||||
}
|
||||
}
|
||||
</style>
|
148
packages/client/src/components/ui/super-menu.vue
Normal file
148
packages/client/src/components/ui/super-menu.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="rrevdjwu" :class="{ grid }">
|
||||
<div class="group" v-for="group in def">
|
||||
<div class="title" v-if="group.title">{{ group.title }}</div>
|
||||
|
||||
<div class="items">
|
||||
<template v-for="(item, i) in group.items">
|
||||
<a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
|
||||
<i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
|
||||
<span class="text">{{ item.text }}</span>
|
||||
</a>
|
||||
<button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active">
|
||||
<i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
|
||||
<span class="text">{{ item.text }}</span>
|
||||
</button>
|
||||
<MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
|
||||
<i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
|
||||
<span class="text">{{ item.text }}</span>
|
||||
</MkA>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, unref } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
def: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
grid: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rrevdjwu {
|
||||
> .group {
|
||||
& + .group {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
> .title {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 8px 12px;
|
||||
}
|
||||
|
||||
> .items {
|
||||
> .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 16px 10px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.9em;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
> .icon {
|
||||
width: 32px;
|
||||
margin-right: 2px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
> .text {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.grid {
|
||||
> .group {
|
||||
& + .group {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
|
||||
> .title {
|
||||
font-size: 1em;
|
||||
opacity: 0.7;
|
||||
margin: 0 0 8px 16px;
|
||||
}
|
||||
|
||||
> .items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
grid-gap: 8px;
|
||||
padding: 0 16px;
|
||||
|
||||
> .item {
|
||||
flex-direction: column;
|
||||
padding: 18px 16px 16px 16px;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
|
||||
> .icon {
|
||||
display: block;
|
||||
margin-right: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
> .text {
|
||||
padding-right: 0;
|
||||
width: 100%;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
92
packages/client/src/components/ui/tooltip.vue
Normal file
92
packages/client/src/components/ui/tooltip.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<transition name="tooltip" appear @after-leave="$emit('closed')">
|
||||
<div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content" :style="{ maxWidth: maxWidth + 'px' }">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
showing: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
source: {
|
||||
required: true,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
maxWidth: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 250,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['closed'],
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
if (this.source == null) {
|
||||
this.$emit('closed');
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = this.source.getBoundingClientRect();
|
||||
|
||||
const contentWidth = this.$refs.content.offsetWidth;
|
||||
const contentHeight = this.$refs.content.offsetHeight;
|
||||
|
||||
let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
|
||||
let top = rect.top + window.pageYOffset - contentHeight;
|
||||
|
||||
left -= (this.$el.offsetWidth / 2);
|
||||
|
||||
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
|
||||
}
|
||||
|
||||
if (top - window.pageYOffset < 0) {
|
||||
top = rect.top + window.pageYOffset + this.source.offsetHeight;
|
||||
this.$refs.content.style.transformOrigin = 'center top';
|
||||
}
|
||||
|
||||
this.$el.style.left = left + 'px';
|
||||
this.$el.style.top = top + 'px';
|
||||
});
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tooltip-enter-active,
|
||||
.tooltip-leave-active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.tooltip-enter-from,
|
||||
.tooltip-leave-active {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
.buebdbiu {
|
||||
position: absolute;
|
||||
z-index: 11000;
|
||||
font-size: 0.8em;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
border: solid 0.5px var(--divider);
|
||||
pointer-events: none;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
</style>
|
525
packages/client/src/components/ui/window.vue
Normal file
525
packages/client/src/components/ui/window.vue
Normal file
@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')">
|
||||
<div class="ebkgocck" :class="{ front }" v-if="showing">
|
||||
<div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
|
||||
<div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
|
||||
<span class="left">
|
||||
<slot name="headerLeft"></slot>
|
||||
</span>
|
||||
<span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
|
||||
<slot name="header"></slot>
|
||||
</span>
|
||||
<span class="right">
|
||||
<slot name="headerRight"></slot>
|
||||
<button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body" v-if="padding">
|
||||
<div class="_section">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="canResize">
|
||||
<div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div>
|
||||
<div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div>
|
||||
<div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div>
|
||||
<div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div>
|
||||
<div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div>
|
||||
<div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div>
|
||||
<div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div>
|
||||
<div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import * as os from '@/os';
|
||||
|
||||
const minHeight = 50;
|
||||
const minWidth = 250;
|
||||
|
||||
function dragListen(fn) {
|
||||
window.addEventListener('mousemove', fn);
|
||||
window.addEventListener('touchmove', fn);
|
||||
window.addEventListener('mouseleave', dragClear.bind(null, fn));
|
||||
window.addEventListener('mouseup', dragClear.bind(null, fn));
|
||||
window.addEventListener('touchend', dragClear.bind(null, fn));
|
||||
}
|
||||
|
||||
function dragClear(fn) {
|
||||
window.removeEventListener('mousemove', fn);
|
||||
window.removeEventListener('touchmove', fn);
|
||||
window.removeEventListener('mouseleave', dragClear);
|
||||
window.removeEventListener('mouseup', dragClear);
|
||||
window.removeEventListener('touchend', dragClear);
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
provide: {
|
||||
inWindow: true
|
||||
},
|
||||
|
||||
props: {
|
||||
padding: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
initialWidth: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 400
|
||||
},
|
||||
initialHeight: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
canResize: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
closeButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
mini: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
front: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
contextmenu: {
|
||||
type: Array,
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['closed'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
showing: true,
|
||||
id: Math.random().toString(), // TODO: UUIDとかにする
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.initialWidth) this.applyTransformWidth(this.initialWidth);
|
||||
if (this.initialHeight) this.applyTransformHeight(this.initialHeight);
|
||||
|
||||
this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2));
|
||||
this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2));
|
||||
|
||||
os.windows.set(this.id, {
|
||||
z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex)
|
||||
});
|
||||
|
||||
// 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする
|
||||
this.top();
|
||||
|
||||
window.addEventListener('resize', this.onBrowserResize);
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
os.windows.delete(this.id);
|
||||
window.removeEventListener('resize', this.onBrowserResize);
|
||||
},
|
||||
|
||||
methods: {
|
||||
close() {
|
||||
this.showing = false;
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
if (e.which === 27) { // Esc
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
|
||||
onContextmenu(e) {
|
||||
if (this.contextmenu) {
|
||||
os.contextMenu(this.contextmenu, e);
|
||||
}
|
||||
},
|
||||
|
||||
// 最前面へ移動
|
||||
top() {
|
||||
let z = 0;
|
||||
const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v);
|
||||
for (const w of ws) {
|
||||
if (w.z > z) z = w.z;
|
||||
}
|
||||
if (z > 0) {
|
||||
(this.$el as any).style.zIndex = z + 1;
|
||||
os.windows.set(this.id, {
|
||||
z: z + 1
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onBodyMousedown() {
|
||||
this.top();
|
||||
},
|
||||
|
||||
onHeaderMousedown(e) {
|
||||
const main = this.$el as any;
|
||||
|
||||
if (!contains(main, document.activeElement)) main.focus();
|
||||
|
||||
const position = main.getBoundingClientRect();
|
||||
|
||||
const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX;
|
||||
const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY;
|
||||
const moveBaseX = clickX - position.left;
|
||||
const moveBaseY = clickY - position.top;
|
||||
const browserWidth = window.innerWidth;
|
||||
const browserHeight = window.innerHeight;
|
||||
const windowWidth = main.offsetWidth;
|
||||
const windowHeight = main.offsetHeight;
|
||||
|
||||
// 動かした時
|
||||
dragListen(me => {
|
||||
const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX;
|
||||
const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY;
|
||||
|
||||
let moveLeft = x - moveBaseX;
|
||||
let moveTop = y - moveBaseY;
|
||||
|
||||
// 下はみ出し
|
||||
if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight;
|
||||
|
||||
// 左はみ出し
|
||||
if (moveLeft < 0) moveLeft = 0;
|
||||
|
||||
// 上はみ出し
|
||||
if (moveTop < 0) moveTop = 0;
|
||||
|
||||
// 右はみ出し
|
||||
if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth;
|
||||
|
||||
this.$el.style.left = moveLeft + 'px';
|
||||
this.$el.style.top = moveTop + 'px';
|
||||
});
|
||||
},
|
||||
|
||||
// 上ハンドル掴み時
|
||||
onTopHandleMousedown(e) {
|
||||
const main = this.$el as any;
|
||||
|
||||
const base = e.clientY;
|
||||
const height = parseInt(getComputedStyle(main, '').height, 10);
|
||||
const top = parseInt(getComputedStyle(main, '').top, 10);
|
||||
|
||||
// 動かした時
|
||||
dragListen(me => {
|
||||
const move = me.clientY - base;
|
||||
if (top + move > 0) {
|
||||
if (height + -move > minHeight) {
|
||||
this.applyTransformHeight(height + -move);
|
||||
this.applyTransformTop(top + move);
|
||||
} else { // 最小の高さより小さくなろうとした時
|
||||
this.applyTransformHeight(minHeight);
|
||||
this.applyTransformTop(top + (height - minHeight));
|
||||
}
|
||||
} else { // 上のはみ出し時
|
||||
this.applyTransformHeight(top + height);
|
||||
this.applyTransformTop(0);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 右ハンドル掴み時
|
||||
onRightHandleMousedown(e) {
|
||||
const main = this.$el as any;
|
||||
|
||||
const base = e.clientX;
|
||||
const width = parseInt(getComputedStyle(main, '').width, 10);
|
||||
const left = parseInt(getComputedStyle(main, '').left, 10);
|
||||
const browserWidth = window.innerWidth;
|
||||
|
||||
// 動かした時
|
||||
dragListen(me => {
|
||||
const move = me.clientX - base;
|
||||
if (left + width + move < browserWidth) {
|
||||
if (width + move > minWidth) {
|
||||
this.applyTransformWidth(width + move);
|
||||
} else { // 最小の幅より小さくなろうとした時
|
||||
this.applyTransformWidth(minWidth);
|
||||
}
|
||||
} else { // 右のはみ出し時
|
||||
this.applyTransformWidth(browserWidth - left);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 下ハンドル掴み時
|
||||
onBottomHandleMousedown(e) {
|
||||
const main = this.$el as any;
|
||||
|
||||
const base = e.clientY;
|
||||
const height = parseInt(getComputedStyle(main, '').height, 10);
|
||||
const top = parseInt(getComputedStyle(main, '').top, 10);
|
||||
const browserHeight = window.innerHeight;
|
||||
|
||||
// 動かした時
|
||||
dragListen(me => {
|
||||
const move = me.clientY - base;
|
||||
if (top + height + move < browserHeight) {
|
||||
if (height + move > minHeight) {
|
||||
this.applyTransformHeight(height + move);
|
||||
} else { // 最小の高さより小さくなろうとした時
|
||||
this.applyTransformHeight(minHeight);
|
||||
}
|
||||
} else { // 下のはみ出し時
|
||||
this.applyTransformHeight(browserHeight - top);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 左ハンドル掴み時
|
||||
onLeftHandleMousedown(e) {
|
||||
const main = this.$el as any;
|
||||
|
||||
const base = e.clientX;
|
||||
const width = parseInt(getComputedStyle(main, '').width, 10);
|
||||
const left = parseInt(getComputedStyle(main, '').left, 10);
|
||||
|
||||
// 動かした時
|
||||
dragListen(me => {
|
||||
const move = me.clientX - base;
|
||||
if (left + move > 0) {
|
||||
if (width + -move > minWidth) {
|
||||
this.applyTransformWidth(width + -move);
|
||||
this.applyTransformLeft(left + move);
|
||||
} else { // 最小の幅より小さくなろうとした時
|
||||
this.applyTransformWidth(minWidth);
|
||||
this.applyTransformLeft(left + (width - minWidth));
|
||||
}
|
||||
} else { // 左のはみ出し時
|
||||
this.applyTransformWidth(left + width);
|
||||
this.applyTransformLeft(0);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 左上ハンドル掴み時
|
||||
onTopLeftHandleMousedown(e) {
|
||||
this.onTopHandleMousedown(e);
|
||||
this.onLeftHandleMousedown(e);
|
||||
},
|
||||
|
||||
// 右上ハンドル掴み時
|
||||
onTopRightHandleMousedown(e) {
|
||||
this.onTopHandleMousedown(e);
|
||||
this.onRightHandleMousedown(e);
|
||||
},
|
||||
|
||||
// 右下ハンドル掴み時
|
||||
onBottomRightHandleMousedown(e) {
|
||||
this.onBottomHandleMousedown(e);
|
||||
this.onRightHandleMousedown(e);
|
||||
},
|
||||
|
||||
// 左下ハンドル掴み時
|
||||
onBottomLeftHandleMousedown(e) {
|
||||
this.onBottomHandleMousedown(e);
|
||||
this.onLeftHandleMousedown(e);
|
||||
},
|
||||
|
||||
// 高さを適用
|
||||
applyTransformHeight(height) {
|
||||
if (height > window.innerHeight) height = window.innerHeight;
|
||||
(this.$el as any).style.height = height + 'px';
|
||||
},
|
||||
|
||||
// 幅を適用
|
||||
applyTransformWidth(width) {
|
||||
if (width > window.innerWidth) width = window.innerWidth;
|
||||
(this.$el as any).style.width = width + 'px';
|
||||
},
|
||||
|
||||
// Y座標を適用
|
||||
applyTransformTop(top) {
|
||||
(this.$el as any).style.top = top + 'px';
|
||||
},
|
||||
|
||||
// X座標を適用
|
||||
applyTransformLeft(left) {
|
||||
(this.$el as any).style.left = left + 'px';
|
||||
},
|
||||
|
||||
onBrowserResize() {
|
||||
const main = this.$el as any;
|
||||
const position = main.getBoundingClientRect();
|
||||
const browserWidth = window.innerWidth;
|
||||
const browserHeight = window.innerHeight;
|
||||
const windowWidth = main.offsetWidth;
|
||||
const windowHeight = main.offsetHeight;
|
||||
if (position.left < 0) main.style.left = 0; // 左はみ出し
|
||||
if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
|
||||
if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
|
||||
if (position.top < 0) main.style.top = 0; // 上はみ出し
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.window-enter-active, .window-leave-active {
|
||||
transition: opacity 0.2s, transform 0.2s !important;
|
||||
}
|
||||
.window-enter-from, .window-leave-to {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.ebkgocck {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000; // mk-modalのと同じでなければならない
|
||||
|
||||
&.front {
|
||||
z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない
|
||||
}
|
||||
|
||||
> .body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
contain: content;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> .header {
|
||||
--height: 50px;
|
||||
|
||||
&.mini {
|
||||
--height: 38px;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
height: var(--height);
|
||||
border-bottom: solid 1px var(--divider);
|
||||
|
||||
> .left, > .right {
|
||||
> ::v-deep(button) {
|
||||
height: var(--height);
|
||||
width: var(--height);
|
||||
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .title {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
line-height: var(--height);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> .handle {
|
||||
$size: 8px;
|
||||
|
||||
position: absolute;
|
||||
|
||||
&.top {
|
||||
top: -($size);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: $size;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&.right {
|
||||
top: 0;
|
||||
right: -($size);
|
||||
width: $size;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
bottom: -($size);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: $size;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&.left {
|
||||
top: 0;
|
||||
left: -($size);
|
||||
width: $size;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&.top-left {
|
||||
top: -($size);
|
||||
left: -($size);
|
||||
width: $size * 2;
|
||||
height: $size * 2;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: -($size);
|
||||
right: -($size);
|
||||
width: $size * 2;
|
||||
height: $size * 2;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
&.bottom-right {
|
||||
bottom: -($size);
|
||||
right: -($size);
|
||||
width: $size * 2;
|
||||
height: $size * 2;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: -($size);
|
||||
left: -($size);
|
||||
width: $size * 2;
|
||||
height: $size * 2;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user