diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-10-17 20:12:00 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-10-17 20:12:00 +0900 |
| commit | 7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a (patch) | |
| tree | 2263a06acec7fa21882366bae26d1a983ce21135 /src/client/components/ui | |
| parent | CW の input でも投稿ショートカットが動作するように (#6690) (diff) | |
| download | sharkey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.gz sharkey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.bz2 sharkey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.zip | |
Migrate to Vue3 (#6587)
* Update reaction.vue
* fix bug
* wip
* wip
* wjio
* wip
* Revert "wip"
This reverts commit e427f2160adf4e8a4147006e25a89854edab0033.
* wip
* wip
* wip
* Update init.ts
* Update drive-window.vue
* wip
* wip
* Use PascalCase for components
* Use PascalCase for components
* update dep
* wip
* wip
* wip
* Update init.ts
* wip
* Update paging.ts
* Update test.vue
* watch deep
* wip
* lint
* wip
* wip
* wip
* wip
* wiop
* wip
* Update webpack.config.ts
* alllow null poll
* wip
* wip
* wip
* wiop
* UI redesign & refactor (#6714)
* wip
* wip
* wip
* wip
* wip
* Update drive.vue
* Update word-mute.vue
* wip
* wip
* wip
* clean up
* wip
* Update default.vue
* wip
* Update notes.vue
* Update mfm.ts
* Update index.home.vue
* Update post-form.vue
* Update post-form-attaches.vue
* wip
* Update post-form.vue
* Update sidebar.vue
* wip
* wip
* Update index.vue
* wip
* Update default.vue
* Update index.vue
* Update index.vue
* wip
* Update post-form-attaches.vue
* Update note.vue
* wip
* clean up
* Update notes.vue
* wip
* wip
* Update ja-JP.yml
* wip
* wip
* Update index.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update default.vue
* wip
* Update _dark.json5
* wip
* wip
* wip
* clean up
* wip
* wip
* Update index.vue
* Update test.vue
* wip
* wip
* fix
* wip
* wip
* wip
* wip
* clena yop
* wip
* wip
* Update store.ts
* Update messaging-room.vue
* Update default.widgets.vue
* fix
* wip
* wip
* Update modal.vue
* wip
* Update os.ts
* Update os.ts
* Update deck.vue
* Update init.ts
* wip
* Update ja-JP.yml
* v-sizeは単にwindowのresizeを監視するだけで良いかもしれない
* Update modal.vue
* wip
* Update tooltip.ts
* wip
* wip
* wip
* wip
* wip
* Update image-viewer.vue
* wip
* wip
* Update style.scss
* Update style.scss
* Update visitor.vue
* wip
* Update init.ts
* Update init.ts
* wip
* wip
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* wip
* wip
* Update modal.vue
* Update header.vue
* Update menu.vue
* Update about.vue
* Update about-misskey.vue
* wip
* wip
* Update visitor.vue
* Update tooltip.ts
* wip
* Update drive.vue
* wip
* Update style.scss
* Update header.vue
* wip
* wip
* Update users.user.vue
* Update announcements.vue
* wip
* wip
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update style.scss
* Update users.vue
* wip
* Update style.scss
* wip
* Update welcome.entrance.vue
* Update radio.vue
* Update size.ts
* Update emoji-edit-dialog.vue
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* wip
* wip
* wip
* wip
* Update file-dialog.vue
* wip
* wip
* Update token-generate-window.vue
* Update notification-setting-window.vue
* wip
* wip
* Update _error_.vue
* Update ja-JP.yml
* wip
* wip
* Update store.ts
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* Update announcements.vue
* Update store.ts
* wip
* Update page-editor.vue
* wip
* wip
* Update modal.vue
* wip
* Update select-file.ts
* Update timeline.vue
* Update emojis.vue
* Update os.ts
* wip
* Update user-select.vue
* Update mfm.ts
* Update get-file-info.ts
* Update drive.vue
* Update init.ts
* Update mfm.ts
* wip
* wip
* Update window.vue
* Update note.vue
* wip
* wip
* Update user-info.vue
* wip
* wip
* wip
* wip
* wip
* Update header.vue
* Update header.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update webpack.config.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update autocomplete.ts
* wip
* wip
* wip
* Update toast.vue
* wip
* Update post-form-dialog.vue
* wip
* wip
* wip
* wip
* wip
* Update users.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update package.json
* wip
* Update icon-dialog.vue
* wip
* wip
* Update user-preview.ts
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* Update user-name.vue
* Update federation.vue
* Update instance.vue
* wip
* wip
* Update tag.vue
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* wip
* Update os.ts
* Update os.ts
* wip
* wip
* wip
* Update router.ts
* wip
* Update init.ts
* Update note.vue
* Update messages.vue
* wip
* wip
* wip
* wip
* wip
* google
* wip
* wip
* wip
* wip
* Update theme-editor.vue
* wip
* wip
* Update room.vue
* Update channel-editor.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update menu.vue
* wip
* wip
* wip
* wip
* Update messaging-room.vue
* wip
* Update post-form.vue
* Update default.widgets.vue
* Update window.vue
* wip
Diffstat (limited to 'src/client/components/ui')
| -rw-r--r-- | src/client/components/ui/button.vue | 44 | ||||
| -rw-r--r-- | src/client/components/ui/container.vue | 30 | ||||
| -rw-r--r-- | src/client/components/ui/context-menu.vue | 63 | ||||
| -rw-r--r-- | src/client/components/ui/folder.vue | 32 | ||||
| -rw-r--r-- | src/client/components/ui/hr.vue | 5 | ||||
| -rw-r--r-- | src/client/components/ui/info.vue | 9 | ||||
| -rw-r--r-- | src/client/components/ui/input.vue | 262 | ||||
| -rw-r--r-- | src/client/components/ui/menu.vue | 237 | ||||
| -rw-r--r-- | src/client/components/ui/modal-menu.vue | 47 | ||||
| -rw-r--r-- | src/client/components/ui/modal-window.vue | 145 | ||||
| -rw-r--r-- | src/client/components/ui/modal.vue | 232 | ||||
| -rw-r--r-- | src/client/components/ui/pagination.vue | 12 | ||||
| -rw-r--r-- | src/client/components/ui/radio.vue | 17 | ||||
| -rw-r--r-- | src/client/components/ui/range.vue | 7 | ||||
| -rw-r--r-- | src/client/components/ui/select.vue | 10 | ||||
| -rw-r--r-- | src/client/components/ui/switch.vue | 11 | ||||
| -rw-r--r-- | src/client/components/ui/textarea.vue | 7 | ||||
| -rw-r--r-- | src/client/components/ui/tooltip.vue | 74 | ||||
| -rw-r--r-- | src/client/components/ui/window.vue | 481 |
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> |