summaryrefslogtreecommitdiff
path: root/src/client/components/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/components/ui')
-rw-r--r--src/client/components/ui/button.vue44
-rw-r--r--src/client/components/ui/container.vue30
-rw-r--r--src/client/components/ui/context-menu.vue63
-rw-r--r--src/client/components/ui/folder.vue32
-rw-r--r--src/client/components/ui/hr.vue5
-rw-r--r--src/client/components/ui/info.vue9
-rw-r--r--src/client/components/ui/input.vue262
-rw-r--r--src/client/components/ui/menu.vue237
-rw-r--r--src/client/components/ui/modal-menu.vue47
-rw-r--r--src/client/components/ui/modal-window.vue145
-rw-r--r--src/client/components/ui/modal.vue232
-rw-r--r--src/client/components/ui/pagination.vue12
-rw-r--r--src/client/components/ui/radio.vue17
-rw-r--r--src/client/components/ui/range.vue7
-rw-r--r--src/client/components/ui/select.vue10
-rw-r--r--src/client/components/ui/switch.vue11
-rw-r--r--src/client/components/ui/textarea.vue7
-rw-r--r--src/client/components/ui/tooltip.vue74
-rw-r--r--src/client/components/ui/window.vue481
19 files changed, 1457 insertions, 268 deletions
diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue
index e5abf37be3..58b0f7b6d0 100644
--- a/src/client/components/ui/button.vue
+++ b/src/client/components/ui/button.vue
@@ -1,7 +1,7 @@
<template>
<component class="bghgjjyj _button"
:is="link ? 'a' : 'button'"
- :class="{ inline, primary }"
+ :class="{ inline, primary, danger, full }"
:type="type"
@click="$emit('click', $event)"
@mousedown="onMousedown"
@@ -14,8 +14,9 @@
</template>
<script lang="ts">
-import Vue from 'vue';
-export default Vue.extend({
+import { defineComponent } from 'vue';
+
+export default defineComponent({
props: {
type: {
type: String,
@@ -46,7 +47,18 @@ export default Vue.extend({
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(() => {
@@ -100,6 +112,7 @@ export default Vue.extend({
<style lang="scss" scoped>
.bghgjjyj {
position: relative;
+ z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
display: block;
min-width: 100px;
padding: 8px 14px;
@@ -121,6 +134,10 @@ export default Vue.extend({
background: var(--buttonHoverBg);
}
+ &.full {
+ width: 100%;
+ }
+
&.primary {
color: #fff;
background: var(--accent);
@@ -134,6 +151,23 @@ export default Vue.extend({
}
}
+ &.danger {
+ color: #ff2a2a;
+
+ &.primary {
+ color: #fff;
+ background: #ff2a2a;
+
+ &:not(:disabled):hover {
+ background: #ff4242;
+ }
+
+ &:not(:disabled):active {
+ background: #d42e2e;
+ }
+ }
+ }
+
&:disabled {
opacity: 0.7;
}
@@ -180,7 +214,7 @@ export default Vue.extend({
border-radius: 6px;
overflow: hidden;
- ::v-deep div {
+ ::v-deep(div) {
position: absolute;
width: 2px;
height: 2px;
@@ -192,7 +226,7 @@ export default Vue.extend({
}
}
- &.primary > .ripples ::v-deep div {
+ &.primary > .ripples ::v-deep(div) {
background: rgba(0, 0, 0, 0.15);
}
diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue
index 382dd76eff..a47b174e8c 100644
--- a/src/client/components/ui/container.vue
+++ b/src/client/components/ui/container.vue
@@ -1,12 +1,12 @@
<template>
-<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380], el: resizeBaseEl }">
+<div class="ukygtjoj _panel" :class="{ naked, 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="bodyTogglable" @click="() => showBody = !showBody">
- <template v-if="showBody"><fa :icon="faAngleUp"/></template>
- <template v-else><fa :icon="faAngleDown"/></template>
+ <template v-if="showBody"><Fa :icon="faAngleUp"/></template>
+ <template v-else><Fa :icon="faAngleDown"/></template>
</button>
</div>
</header>
@@ -24,10 +24,10 @@
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
-export default Vue.extend({
+export default defineComponent({
props: {
showHeader: {
type: Boolean,
@@ -54,9 +54,6 @@ export default Vue.extend({
required: false,
default: false
},
- resizeBaseEl: {
- required: false,
- },
},
data() {
return {
@@ -66,11 +63,12 @@ export default Vue.extend({
},
mounted() {
this.$watch('showBody', showBody => {
- this.$el.style.minHeight = `${this.$refs.header.offsetHeight}px`;
+ 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 = `${this.$refs.header.offsetHeight}px`;
+ this.$el.style.flexBasis = `${headerHeight}px`;
}
}, {
immediate: true
@@ -109,7 +107,7 @@ export default Vue.extend({
overflow-y: hidden;
transition: opacity 0.5s, height 0.5s !important;
}
-.container-toggle-enter {
+.container-toggle-enter-from {
opacity: 0;
}
.container-toggle-leave-to {
@@ -138,15 +136,13 @@ export default Vue.extend({
position: relative;
box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
z-index: 2;
- background: var(--panelHeaderBg);
- color: var(--panelHeaderFg);
line-height: 1.4em;
> .title {
margin: 0;
padding: 12px 16px;
- > [data-icon] {
+ > ::v-deep([data-icon]) {
margin-right: 6px;
}
@@ -162,7 +158,7 @@ export default Vue.extend({
right: 0;
height: 100%;
- > button {
+ > ::v-deep(button) {
width: 42px;
height: 100%;
}
@@ -170,7 +166,7 @@ export default Vue.extend({
}
> div {
- > ::v-deep ._content {
+ > ::v-deep(._content) {
padding: 24px;
& + ._content {
@@ -187,7 +183,7 @@ export default Vue.extend({
}
> div {
- > ::v-deep ._content {
+ > ::v-deep(._content) {
padding: 16px;
}
}
diff --git a/src/client/components/ui/context-menu.vue b/src/client/components/ui/context-menu.vue
new file mode 100644
index 0000000000..98586cf3fe
--- /dev/null
+++ b/src/client/components/ui/context-menu.vue
@@ -0,0 +1,63 @@
+<template>
+<div class="nvlagfpb">
+ <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/>
+</div>
+</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() {
+ this.$el.style.top = this.ev.pageY + 'px';
+ this.$el.style.left = this.ev.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: {
+ 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;
+}
+</style>
diff --git a/src/client/components/ui/folder.vue b/src/client/components/ui/folder.vue
index 0b489fe9ad..1eaf881ffe 100644
--- a/src/client/components/ui/folder.vue
+++ b/src/client/components/ui/folder.vue
@@ -1,11 +1,11 @@
<template>
<div class="ssazuxis" v-size="{ max: [500] }">
- <header @click="() => showBody = !showBody" class="_button">
+ <header @click="showBody = !showBody" class="_button">
<div class="title"><slot name="header"></slot></div>
<div class="divider"></div>
<button class="_button">
- <template v-if="showBody"><fa :icon="faAngleUp"/></template>
- <template v-else><fa :icon="faAngleDown"/></template>
+ <template v-if="showBody"><Fa :icon="faAngleUp"/></template>
+ <template v-else><Fa :icon="faAngleDown"/></template>
</button>
</header>
<transition name="folder-toggle"
@@ -22,23 +22,37 @@
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
-export default Vue.extend({
+const localStoragePrefix = 'ui:folder:';
+
+export default defineComponent({
props: {
expanded: {
type: Boolean,
required: false,
default: true
},
+ persistKey: {
+ type: String,
+ required: false,
+ default: null
+ },
},
data() {
return {
- showBody: this.expanded,
+ showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
faAngleUp, faAngleDown
};
},
+ watch: {
+ showBody() {
+ if (this.persistKey) {
+ localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
+ }
+ }
+ },
methods: {
toggleContent(show: boolean) {
this.showBody = show;
@@ -71,7 +85,7 @@ export default Vue.extend({
overflow-y: hidden;
transition: opacity 0.5s, height 0.5s !important;
}
-.folder-toggle-enter {
+.folder-toggle-enter-from {
opacity: 0;
}
.folder-toggle-leave-to {
@@ -92,7 +106,7 @@ export default Vue.extend({
> .title {
margin: 0;
- padding: 12px 16px 12px 8px;
+ padding: 12px 16px 12px 0;
> [data-icon] {
margin-right: 6px;
@@ -111,7 +125,7 @@ export default Vue.extend({
}
> button {
- width: 42px;
+ padding: 12px 0 12px 16px;
}
}
diff --git a/src/client/components/ui/hr.vue b/src/client/components/ui/hr.vue
index ae7f7dbf8e..6b075cb440 100644
--- a/src/client/components/ui/hr.vue
+++ b/src/client/components/ui/hr.vue
@@ -3,8 +3,9 @@
</template>
<script lang="ts">
-import Vue from 'vue';
-export default Vue.extend({});
+import { defineComponent } from 'vue';import * as os from '@/os';
+
+export default defineComponent({});
</script>
<style lang="scss" scoped>
diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue
index 3e87fe261d..3bdb69b3d1 100644
--- a/src/client/components/ui/info.vue
+++ b/src/client/components/ui/info.vue
@@ -1,16 +1,17 @@
<template>
<div class="fpezltsf" :class="{ warn }">
- <i v-if="warn"><fa :icon="faExclamationTriangle"/></i>
- <i v-else><fa :icon="faInfoCircle"/></i>
+ <i v-if="warn"><Fa :icon="faExclamationTriangle"/></i>
+ <i v-else><Fa :icon="faInfoCircle"/></i>
<slot></slot>
</div>
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
import { faInfoCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
+import * as os from '@/os';
-export default Vue.extend({
+export default defineComponent({
props: {
warn: {
type: Boolean,
diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue
index f9c2d9a43a..dec4a08712 100644
--- a/src/client/components/ui/input.vue
+++ b/src/client/components/ui/input.vue
@@ -2,66 +2,51 @@
<div class="juejbjww" :class="{ focused, filled, inline, disabled }">
<div class="icon" ref="icon"><slot name="icon"></slot></div>
<div class="input">
- <span class="label" ref="label"><slot></slot></span>
+ <span class="label" ref="labelEl"><slot></slot></span>
<span class="title" ref="title">
<slot name="title"></slot>
- <span class="warning" v-if="invalid"><fa :icon="faExclamationCircle"/>{{ $refs.input.validationMessage }}</span>
+ <span class="warning" v-if="invalid"><Fa :icon="faExclamationCircle"/>{{ $refs.input.validationMessage }}</span>
</span>
- <div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
- <template v-if="type != 'file'">
- <input v-if="debounce" ref="input"
- v-debounce="500"
- :type="type"
- v-model.lazy="v"
- :disabled="disabled"
- :required="required"
- :readonly="readonly"
- :placeholder="placeholder"
- :pattern="pattern"
- :autocomplete="autocomplete"
- :spellcheck="spellcheck"
- :step="step"
- @focus="focused = true"
- @blur="focused = false"
- @keydown="$emit('keydown', $event)"
- @input="onInput"
- :list="id"
- >
- <input v-else ref="input"
- :type="type"
- v-model="v"
- :disabled="disabled"
- :required="required"
- :readonly="readonly"
- :placeholder="placeholder"
- :pattern="pattern"
- :autocomplete="autocomplete"
- :spellcheck="spellcheck"
- :step="step"
- @focus="focused = true"
- @blur="focused = false"
- @keydown="$emit('keydown', $event)"
- @input="onInput"
- :list="id"
- >
- <datalist :id="id" v-if="datalist">
- <option v-for="data in datalist" :value="data"/>
- </datalist>
- </template>
- <template v-else>
- <input ref="input"
- type="text"
- :value="filePlaceholder"
- readonly
- @click="chooseFile"
- >
- <input ref="file"
- type="file"
- :value="value"
- @change="onChangeFile"
- >
- </template>
- <div class="suffix" ref="suffix"><slot name="suffix"></slot></div>
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <input v-if="debounce" ref="inputEl"
+ v-debounce="500"
+ :type="type"
+ v-model.lazy="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ :list="id"
+ >
+ <input v-else ref="inputEl"
+ :type="type"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ :list="id"
+ >
+ <datalist :id="id" v-if="datalist">
+ <option v-for="data in datalist" :value="data"/>
+ </datalist>
+ <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
</div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
<div class="desc _caption"><slot name="desc"></slot></div>
@@ -69,11 +54,12 @@
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
import debounce from 'v-debounce';
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
+import * as os from '@/os';
-export default Vue.extend({
+export default defineComponent({
directives: {
debounce
},
@@ -136,106 +122,92 @@ export default Vue.extend({
required: false,
},
},
- data() {
- return {
- v: this.value,
- focused: false,
- invalid: false,
- changed: false,
- id: Math.random().toString(),
- faExclamationCircle
- };
- },
- computed: {
- filled(): boolean {
- return this.v !== '' && this.v != null;
- },
- filePlaceholder(): string | null {
- if (this.type != 'file') return null;
- if (this.v == null) return null;
+ emits: ['change', 'keydown', 'enter'],
+ setup(props, context) {
+ const { value, type, autofocus } = toRefs(props);
+ const v = ref(value.value);
+ const id = Math.random().toString(); // TODO: uuid?
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+ const labelEl = ref(null);
- if (typeof this.v == 'string') return this.v;
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
- if (Array.isArray(this.v)) {
- return this.v.map(file => file.name).join(', ');
- } else {
- return this.v.name;
+ if (ev.code === 'Enter') {
+ context.emit('enter');
}
- }
- },
- watch: {
- value(v) {
- this.v = v;
- },
- v(v) {
- if (this.type === 'number') {
- this.$emit('input', parseFloat(v));
+ };
+
+ watch(value, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (type?.value === 'number') {
+ context.emit('update:value', parseFloat(newValue));
} else {
- this.$emit('input', v);
+ context.emit('update:value', newValue);
}
- this.invalid = this.$refs.input.validity.badInput;
- }
- },
- mounted() {
- if (this.autofocus) {
- this.$nextTick(() => {
- this.$refs.input.focus();
- });
- }
+ invalid.value = inputEl.value.validity.badInput;
+ });
- this.$nextTick(() => {
- // このコンポーネントが作成された時、非表示状態である場合がある
- // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
- const clock = setInterval(() => {
- if (this.$refs.prefix) {
- this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px';
- if (this.$refs.prefix.offsetWidth) {
- this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px';
- }
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
}
- if (this.$refs.suffix) {
- if (this.$refs.suffix.offsetWidth) {
- this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px';
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ labelEl.value.style.left = (prefixEl.value.offsetLeft + prefixEl.value.offsetWidth) + 'px';
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
}
- }
- }, 100);
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
- this.$once('hook:beforeDestroy', () => {
- clearInterval(clock);
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
});
});
- this.$on('keydown', (e: KeyboardEvent) => {
- if (e.code == 'Enter') {
- this.$emit('enter');
- }
- });
+ return {
+ id,
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ labelEl,
+ focus,
+ onInput,
+ onKeydown,
+ faExclamationCircle,
+ };
},
- methods: {
- focus() {
- this.$refs.input.focus();
- },
- togglePassword() {
- if (this.type == 'password') {
- this.type = 'text'
- } else {
- this.type = 'password'
- }
- },
- chooseFile() {
- this.$refs.file.click();
- },
- onChangeFile() {
- this.v = Array.from((this.$refs.file as any).files);
- this.$emit('input', this.v);
- this.$emit('change', this.v);
- },
- onInput(ev) {
- this.changed = true;
- this.$emit('change', ev);
- }
- }
});
</script>
diff --git a/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue
new file mode 100644
index 0000000000..5e74828c20
--- /dev/null
+++ b/src/client/components/ui/menu.vue
@@ -0,0 +1,237 @@
+<template>
+<div class="rrevdjwt" :class="{ left: align === 'left' }"
+ 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>
+ <router-link v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item">
+ <Fa v-if="item.icon" :icon="item.icon" fixed-width/>
+ <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+ <span>{{ item.text }}</span>
+ <i v-if="item.indicate"><Fa :icon="faCircle"/></i>
+ </router-link>
+ <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item">
+ <Fa v-if="item.icon" :icon="item.icon" fixed-width/>
+ <span>{{ item.text }}</span>
+ <i v-if="item.indicate"><Fa :icon="faCircle"/></i>
+ </a>
+ <button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item">
+ <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
+ <i v-if="item.indicate"><Fa :icon="faCircle"/></i>
+ </button>
+ <button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :class="{ danger: item.danger }">
+ <Fa v-if="item.icon" :icon="item.icon" fixed-width/>
+ <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+ <span>{{ item.text }}</span>
+ <i v-if="item.indicate"><Fa :icon="faCircle"/></i>
+ </button>
+ </template>
+ <span v-if="_items.length === 0" class="none item">
+ <span>{{ $t('none') }}</span>
+ </span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { faCircle } from '@fortawesome/free-solid-svg-icons';
+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
+ }
+ },
+ emits: ['close'],
+ data() {
+ return {
+ _items: [],
+ faCircle,
+ };
+ },
+ computed: {
+ keymap(): any {
+ return {
+ 'up|k|shift+tab': this.focusUp,
+ 'down|j|tab': this.focusDown,
+ 'esc': this.close,
+ };
+ },
+ },
+ created() {
+ const items = ref(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;
+ },
+ 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) {
+ fn();
+ 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;
+
+ &.left {
+ > .item {
+ text-align: left;
+ }
+ }
+
+ > .item {
+ display: block;
+ position: relative;
+ padding: 8px 16px;
+ width: 100%;
+ box-sizing: border-box;
+ white-space: nowrap;
+ font-size: 0.9em;
+ line-height: 20px;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.danger {
+ color: #ff2a2a;
+
+ &:hover {
+ color: #fff;
+ background: #ff4242;
+ }
+
+ &:active {
+ color: #fff;
+ background: #d42e2e;
+ }
+ }
+
+ &:hover {
+ color: #fff;
+ background: var(--accent);
+ text-decoration: none;
+ }
+
+ &:active {
+ color: #fff;
+ background: var(--accentDarken);
+ }
+
+ &:not(:active):focus {
+ 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;
+ }
+
+ > [data-icon] {
+ margin-right: 4px;
+ width: 20px;
+ }
+
+ > .avatar {
+ margin-right: 4px;
+ width: 20px;
+ height: 20px;
+ }
+
+ > i {
+ 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>
diff --git a/src/client/components/ui/modal-menu.vue b/src/client/components/ui/modal-menu.vue
new file mode 100644
index 0000000000..aac4be9c3b
--- /dev/null
+++ b/src/client/components/ui/modal-menu.vue
@@ -0,0 +1,47 @@
+<template>
+<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <MkMenu :items="items" :align="align" @close="$refs.modal.close()" class="_popup"/>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from './modal.vue';
+import MkMenu from './menu.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkMenu,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ align: {
+ type: String,
+ required: false
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ required: false
+ },
+ },
+ emits: ['closed'],
+ computed: {
+ keymap(): any {
+ return {
+ 'esc': () => this.$refs.modal.close(),
+ };
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/components/ui/modal-window.vue b/src/client/components/ui/modal-window.vue
new file mode 100644
index 0000000000..2cdf961379
--- /dev/null
+++ b/src/client/components/ui/modal-window.vue
@@ -0,0 +1,145 @@
+<template>
+<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
+ <div class="ebkgoccj _popup _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: height ? `${height}px` : null }">
+ <div class="header">
+ <button class="_button" v-if="withOkButton" @click="$emit('close')"><Fa :icon="faTimes"/></button>
+ <span class="title">
+ <slot name="header"></slot>
+ </span>
+ <button class="_button" v-if="!withOkButton" @click="$emit('close')"><Fa :icon="faTimes"/></button>
+ <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><Fa :icon="faCheck"/></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 { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons';
+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,
+ },
+ },
+
+ emits: ['click', 'close', 'closed', 'ok'],
+
+ data() {
+ return {
+ faTimes, faCheck
+ };
+ },
+
+ 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;
+
+ --section-padding: 24px;
+
+ @media (max-width: 500px) {
+ --section-padding: 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>
diff --git a/src/client/components/ui/modal.vue b/src/client/components/ui/modal.vue
new file mode 100644
index 0000000000..4cc96bb8da
--- /dev/null
+++ b/src/client/components/ui/modal.vue
@@ -0,0 +1,232 @@
+<template>
+<div class="mk-modal" v-hotkey.global="keymap" :style="{ pointerEvents: showing ? 'auto' : 'none' }">
+ <transition :name="$store.state.device.animation ? 'modal-bg' : ''" appear>
+ <div class="bg _modalBg" v-if="showing" @click="onBgClick"></div>
+ </transition>
+ <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content">
+ <transition :name="$store.state.device.animation ? popup ? 'modal-popup-content' : 'modal-content' : ''" appear @after-leave="$emit('closed')" @after-enter="childRendered">
+ <slot v-if="showing"></slot>
+ </transition>
+ </div>
+</div>
+</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: {
+ srcCenter: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ required: false,
+ },
+ position: {
+ required: false
+ }
+ },
+ emits: ['click', 'esc', '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.fixed = getFixedContainer(this.src) != null;
+
+ this.$nextTick(() => {
+ if (!this.popup) return;
+
+ const popover = this.$refs.content as any;
+
+ // TODO: ResizeObserver無くしたい
+ new ResizeObserver((entries, observer) => {
+ 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;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset;
+ }
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
+ this.transformOrigin = 'center top';
+ }
+
+ popover.style.left = left + 'px';
+ popover.style.top = top + 'px';
+ }).observe(popover);
+ });
+ },
+ methods: {
+ 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;
+ },
+
+ onBgClick() {
+ if (this.contentClicking) return;
+ this.$emit('click');
+ }
+ }
+});
+</script>
+
+<style vars="{ transformOrigin }">
+.modal-popup-content-enter-active, .modal-popup-content-leave-active,
+.modal-content-enter-from, .modal-content-leave-to {
+ transform-origin: var(--transformOrigin);
+}
+</style>
+
+<style lang="scss" scoped>
+.modal-bg-enter-active, .modal-bg-leave-active {
+ transition: opacity 0.3s !important;
+}
+.modal-bg-enter-from, .modal-bg-leave-to {
+ opacity: 0;
+}
+
+.modal-content-enter-active, .modal-content-leave-active {
+ transition: opacity 0.3s, transform 0.3s !important;
+}
+.modal-content-enter-from, .modal-content-leave-to {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.modal-popup-content-enter-active, .modal-popup-content-leave-active {
+ transition: opacity 0.3s, transform 0.3s !important;
+}
+.modal-popup-content-enter-from, .modal-popup-content-leave-to {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.mk-modal {
+ > .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%);
+ }
+
+ > * {
+ margin: auto;
+ }
+
+ &.top {
+ > * {
+ margin-top: 0;
+ }
+ }
+ }
+
+ > .content.popup {
+ position: absolute;
+ z-index: 10000;
+
+ &.fixed {
+ position: fixed;
+ }
+ }
+}
+</style>
diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue
index 0db6ee20dc..fa584f3aab 100644
--- a/src/client/components/ui/pagination.vue
+++ b/src/client/components/ui/pagination.vue
@@ -5,20 +5,20 @@
<slot name="empty"></slot>
</div>
<div class="more" v-show="more" key="_more_">
- <mk-button class="button" ref="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+ <MkButton class="button" v-appear="$store.state.device.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
- <template v-if="moreFetching"><mk-loading inline/></template>
- </mk-button>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
</div>
</div>
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
import MkButton from './button.vue';
-import paging from '../../scripts/paging';
+import paging from '@/scripts/paging';
-export default Vue.extend({
+export default defineComponent({
components: {
MkButton
},
diff --git a/src/client/components/ui/radio.vue b/src/client/components/ui/radio.vue
index 311cdce32d..8f2b843ee6 100644
--- a/src/client/components/ui/radio.vue
+++ b/src/client/components/ui/radio.vue
@@ -17,14 +17,12 @@
</template>
<script lang="ts">
-import Vue from 'vue';
-export default Vue.extend({
- model: {
- prop: 'model',
- event: 'change'
- },
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
props: {
- model: {
+ modelValue: {
required: false
},
value: {
@@ -37,13 +35,13 @@ export default Vue.extend({
},
computed: {
checked(): boolean {
- return this.model === this.value;
+ return this.modelValue === this.value;
}
},
methods: {
toggle() {
if (this.disabled) return;
- this.$emit('change', this.value);
+ this.$emit('update:modelValue', this.value);
}
}
});
@@ -51,6 +49,7 @@ export default Vue.extend({
<style lang="scss" scoped>
.novjtctn {
+ position: relative;
display: inline-block;
margin: 0 32px 0 0;
cursor: pointer;
diff --git a/src/client/components/ui/range.vue b/src/client/components/ui/range.vue
index 2c815912bb..c6e585cf50 100644
--- a/src/client/components/ui/range.vue
+++ b/src/client/components/ui/range.vue
@@ -13,14 +13,15 @@
:autofocus="autofocus"
@focus="focused = true"
@blur="focused = false"
- @input="$emit('input', $event.target.value)"
+ @input="$emit('update:value', $event.target.value)"
/>
</div>
</template>
<script lang="ts">
-import Vue from "vue";
-export default Vue.extend({
+import { defineComponent } from 'vue';import * as os from '@/os';
+
+export default defineComponent({
props: {
value: {
type: Number,
diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue
index cb737df6ed..aaaddacb29 100644
--- a/src/client/components/ui/select.vue
+++ b/src/client/components/ui/select.vue
@@ -15,7 +15,7 @@
</select>
<div class="suffix">
<slot name="suffix">
- <fa :icon="faChevronDown"/>
+ <Fa :icon="faChevronDown"/>
</slot>
</div>
</div>
@@ -24,10 +24,11 @@
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
+import * as os from '@/os';
-export default Vue.extend({
+export default defineComponent({
props: {
value: {
required: false
@@ -58,7 +59,7 @@ export default Vue.extend({
return this.value;
},
set(v) {
- this.$emit('input', v);
+ this.$emit('update:value', v);
}
},
filled(): boolean {
@@ -169,6 +170,7 @@ export default Vue.extend({
option,
optgroup {
+ color: var(--fg);
background: var(--bg);
}
}
diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue
index 9652a01024..f738257232 100644
--- a/src/client/components/ui/switch.vue
+++ b/src/client/components/ui/switch.vue
@@ -26,12 +26,9 @@
</template>
<script lang="ts">
-import Vue from 'vue';
-export default Vue.extend({
- model: {
- prop: 'value',
- event: 'change'
- },
+import { defineComponent } from 'vue';
+
+export default defineComponent({
props: {
value: {
type: Boolean,
@@ -50,7 +47,7 @@ export default Vue.extend({
methods: {
toggle() {
if (this.disabled) return;
- this.$emit('change', !this.checked);
+ this.$emit('update:value', !this.checked);
}
}
});
diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue
index fba9fc9d78..6820be8a7c 100644
--- a/src/client/components/ui/textarea.vue
+++ b/src/client/components/ui/textarea.vue
@@ -19,9 +19,10 @@
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
+import * as os from '@/os';
-export default Vue.extend({
+export default defineComponent({
props: {
value: {
required: false
@@ -74,7 +75,7 @@ export default Vue.extend({
},
onInput(ev) {
this.changed = true;
- this.$emit('input', ev.target.value);
+ this.$emit('update:value', ev.target.value);
}
}
});
diff --git a/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue
index b7a56708b7..6ea344c54d 100644
--- a/src/client/components/ui/tooltip.vue
+++ b/src/client/components/ui/tooltip.vue
@@ -1,16 +1,20 @@
<template>
-<transition name="zoom-in-top" appear>
- <div class="buebdbiu" v-if="show">
+<transition name="zoom-in-top" appear @after-leave="$emit('closed')">
+ <div class="buebdbiu _acrylic _shadow" v-if="showing">
<slot>{{ text }}</slot>
</div>
</transition>
</template>
<script lang="ts">
-import Vue from 'vue';
+import { defineComponent } from 'vue';
-export default Vue.extend({
+export default defineComponent({
props: {
+ showing: {
+ type: Boolean,
+ required: true,
+ },
source: {
required: true,
},
@@ -20,77 +24,39 @@ export default Vue.extend({
}
},
- data() {
- return {
- show: false
- };
- },
+ emits: ['closed'],
mounted() {
- this.show = true;
-
this.$nextTick(() => {
if (this.source == null) {
- this.destroyDom();
+ this.$emit('closed');
return;
}
+
const rect = this.source.getBoundingClientRect();
- const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
- const y = rect.top + window.pageYOffset + this.source.offsetHeight;
- this.$el.style.left = (x - 28) + 'px';
- this.$el.style.top = (y + 16) + 'px';
+ let x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+ let y = rect.top + window.pageYOffset + this.source.offsetHeight;
+
+ x -= (this.$el.offsetWidth / 2);
+
+ this.$el.style.left = x + 'px';
+ this.$el.style.top = y + 'px';
});
},
-
- methods: {
- close() {
- this.show = false;
- setTimeout(this.destroyDom, 300);
- }
- }
})
</script>
<style lang="scss" scoped>
.buebdbiu {
- z-index: 11000;
- display: block;
position: absolute;
+ z-index: 11000;
max-width: 240px;
font-size: 0.8em;
- padding: 6px 8px;
- background: var(--panel);
+ padding: 8px 12px;
text-align: center;
border-radius: 4px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.25);
pointer-events: none;
transform-origin: center -16px;
-
- &:before {
- content: "";
- pointer-events: none;
- display: block;
- position: absolute;
- top: -28px;
- left: 12px;
- border-top: solid 14px transparent;
- border-right: solid 14px transparent;
- border-bottom: solid 14px rgba(0,0,0,0.1);
- border-left: solid 14px transparent;
- }
-
- &:after {
- content: "";
- pointer-events: none;
- display: block;
- position: absolute;
- top: -27px;
- left: 12px;
- border-top: solid 14px transparent;
- border-right: solid 14px transparent;
- border-bottom: solid 14px var(--panel);
- border-left: solid 14px transparent;
- }
}
</style>
diff --git a/src/client/components/ui/window.vue b/src/client/components/ui/window.vue
new file mode 100644
index 0000000000..cf76347d39
--- /dev/null
+++ b/src/client/components/ui/window.vue
@@ -0,0 +1,481 @@
+<template>
+<transition :name="$store.state.device.animation ? 'window' : ''" appear @after-leave="$emit('closed')">
+ <div class="ebkgocck" v-if="showing">
+ <div class="body _popup _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
+ <div class="header">
+ <button class="_button" @click="close()"><Fa :icon="faTimes"/></button>
+ <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
+ <slot name="header"></slot>
+ </span>
+ <slot name="buttons"></slot>
+ </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 { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons';
+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,
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ showing: true,
+ id: Math.random().toString(), // TODO: UUIDとかにする
+ faTimes
+ };
+ },
+
+ 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)
+ });
+
+ 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();
+ }
+ },
+
+ // 最前面へ移動
+ 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) {
+ (this.$el as any).style.height = height + 'px';
+ },
+
+ // 幅を適用
+ applyTransformWidth(width) {
+ (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.3s, transform 0.3s !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: 5000;
+
+ > .body {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+ width: 100%;
+ height: 100%;
+
+ --section-padding: 16px;
+
+ > .header {
+ $height: 50px;
+ display: flex;
+ position: relative;
+ flex-shrink: 0;
+ box-shadow: 0px 1px var(--divider);
+ cursor: move;
+ user-select: none;
+ height: $height;
+
+ > ::v-deep(button) {
+ height: $height;
+ width: $height;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+
+ > .title {
+ flex: 1;
+ position: relative;
+ line-height: $height;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .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>