diff --git a/src/client/components/emoji-picker-dialog.vue b/src/client/components/emoji-picker-dialog.vue new file mode 100644 index 0000000000..177b5db44d --- /dev/null +++ b/src/client/components/emoji-picker-dialog.vue @@ -0,0 +1,191 @@ +<template> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkEmojiPicker from '@/components/emoji-picker.vue'; + +export default defineComponent({ + components: { + MkModal, + MkEmojiPicker, + }, + + props: { + src: { + required: false + }, + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + + }; + }, + + methods: { + chosen(emoji: any) { + this.$emit('done', emoji); + this.$refs.modal.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + contain: content; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .index { + min-height: var(--height); + position: relative; + border-bottom: solid 1px var(--divider); + + > .arrow { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 16px 0; + text-align: center; + opacity: 0.5; + pointer-events: none; + } + } + + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } + + > div { + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 1px var(--divider); + + &:empty { + display: none; + } + } + + &.unicode { + min-height: 384px; + } + + &.custom { + min-height: 64px; + } + } + } +} +</style> diff --git a/src/client/components/emoji-picker-window.vue b/src/client/components/emoji-picker-window.vue new file mode 100644 index 0000000000..fb6a5a9072 --- /dev/null +++ b/src/client/components/emoji-picker-window.vue @@ -0,0 +1,197 @@ +<template> +<MkWindow ref="window" + :initial-width="null" + :initial-height="null" + :can-resize="false" + :mini="true" + :front="true" + @closed="$emit('closed')" +> + <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> +</MkWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkWindow from '@/components/ui/window.vue'; +import MkEmojiPicker from '@/components/emoji-picker.vue'; + +export default defineComponent({ + components: { + MkWindow, + MkEmojiPicker, + }, + + props: { + src: { + required: false + }, + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['chosen', 'closed'], + + data() { + return { + + }; + }, + + methods: { + chosen(emoji: any) { + this.$emit('chosen', emoji); + }, + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + contain: content; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .index { + min-height: var(--height); + position: relative; + border-bottom: solid 1px var(--divider); + + > .arrow { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 16px 0; + text-align: center; + opacity: 0.5; + pointer-events: none; + } + } + + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } + + > div { + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 1px var(--divider); + + &:empty { + display: none; + } + } + + &.unicode { + min-height: 384px; + } + + &.custom { + min-height: 64px; + } + } + } +} +</style> diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue index 9a261ef83f..93530e16c8 100644 --- a/src/client/components/emoji-picker.vue +++ b/src/client/components/emoji-picker.vue @@ -1,93 +1,91 @@ <template> -<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> - <div class="omfetrab _popup" :class="['w' + width, 'h' + height, { big }]"> - <input ref="search" class="search" :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()"> - <div class="emojis" ref="emojis"> - <section class="result"> - <div v-if="searchResultCustom.length > 0"> - <button v-for="emoji in searchResultCustom" +<div class="omfetrab _popup" :class="['w' + width, 'h' + height, { big }]"> + <input ref="search" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()"> + <div class="emojis" ref="emojis"> + <section class="result"> + <div v-if="searchResultCustom.length > 0"> + <button v-for="emoji in searchResultCustom" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji" + tabindex="0" + > + <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> + <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + <div v-if="searchResultUnicode.length > 0"> + <button v-for="emoji in searchResultUnicode" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + tabindex="0" + > + <MkEmoji :emoji="emoji.char"/> + </button> + </div> + </section> + + <div class="index"> + <section v-if="showPinned"> + <div> + <button v-for="emoji in pinned" + class="_button" + @click="chosen(emoji, $event)" + tabindex="0" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + + <section> + <header class="_acrylic"><Fa :icon="faClock" fixed-width/> {{ $ts.recentUsed }}</header> + <div> + <button v-for="emoji in $store.state.recentlyUsedEmojis" class="_button" - :title="emoji.name" @click="chosen(emoji, $event)" :key="emoji" - tabindex="0" > - <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> - <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> - </button> - </div> - <div v-if="searchResultUnicode.length > 0"> - <button v-for="emoji in searchResultUnicode" - class="_button" - :title="emoji.name" - @click="chosen(emoji, $event)" - :key="emoji.name" - tabindex="0" - > - <MkEmoji :emoji="emoji.char"/> + <MkEmoji :emoji="emoji" :normal="true"/> </button> </div> </section> - <div class="index"> - <section v-if="showPinned"> - <div> - <button v-for="emoji in pinned" - class="_button" - @click="chosen(emoji, $event)" - tabindex="0" - > - <MkEmoji :emoji="emoji" :normal="true"/> - </button> - </div> - </section> - - <section> - <header class="_acrylic"><Fa :icon="faClock" fixed-width/> {{ $ts.recentUsed }}</header> - <div> - <button v-for="emoji in $store.state.recentlyUsedEmojis" - class="_button" - @click="chosen(emoji, $event)" - :key="emoji" - > - <MkEmoji :emoji="emoji" :normal="true"/> - </button> - </div> - </section> - - <div class="arrow"><Fa :icon="faChevronDown"/></div> - </div> - - <section v-for="category in customEmojiCategories" :key="'custom:' + category" class="custom"> - <header class="_acrylic" v-appear="() => visibleCategories[category] = true">{{ category || $ts.other }}</header> - <div v-if="visibleCategories[category]"> - <button v-for="emoji in customEmojis.filter(e => e.category === category)" - class="_button" - :title="emoji.name" - @click="chosen(emoji, $event)" - :key="emoji.name" - > - <img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> - </button> - </div> - </section> - - <section v-for="category in categories" :key="category.name" class="unicode"> - <header class="_acrylic" v-appear="() => category.isActive = true"><Fa :icon="category.icon" fixed-width/> {{ category.name }}</header> - <div v-if="category.isActive"> - <button v-for="emoji in emojilist.filter(e => e.category === category.name)" - class="_button" - :title="emoji.name" - @click="chosen(emoji, $event)" - :key="emoji.name" - > - <MkEmoji :emoji="emoji.char"/> - </button> - </div> - </section> + <div class="arrow"><Fa :icon="faChevronDown"/></div> </div> + + <section v-for="category in customEmojiCategories" :key="'custom:' + category" class="custom"> + <header class="_acrylic" v-appear="() => visibleCategories[category] = true">{{ category || $ts.other }}</header> + <div v-if="visibleCategories[category]"> + <button v-for="emoji in customEmojis.filter(e => e.category === category)" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + > + <img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + </section> + + <section v-for="category in categories" :key="category.name" class="unicode"> + <header class="_acrylic" v-appear="() => category.isActive = true"><Fa :icon="category.icon" fixed-width/> {{ category.name }}</header> + <div v-if="category.isActive"> + <button v-for="emoji in emojilist.filter(e => e.category === category.name)" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + > + <MkEmoji :emoji="emoji.char"/> + </button> + </div> + </section> </div> -</MkModal> +</div> </template> <script lang="ts"> @@ -96,7 +94,6 @@ import { emojilist } from '../../misc/emojilist'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faClock, faUser, faChevronDown } from '@fortawesome/free-solid-svg-icons'; import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons'; -import MkModal from '@/components/ui/modal.vue'; import Particle from '@/components/particle.vue'; import * as os from '@/os'; import { isDeviceTouch } from '@/scripts/is-device-touch'; @@ -104,14 +101,7 @@ import { isMobile } from '@/scripts/is-mobile'; import { emojiCategories } from '@/instance'; export default defineComponent({ - components: { - MkModal, - }, - props: { - src: { - required: false - }, showPinned: { required: false, default: true @@ -121,7 +111,7 @@ export default defineComponent({ }, }, - emits: ['done', 'closed'], + emits: ['chosen'], data() { return { @@ -345,8 +335,7 @@ export default defineComponent({ } const key = this.getKey(emoji); - this.$emit('done', key); - this.$refs.modal.close(); + this.$emit('chosen', key); // 最近使った絵文字更新 if (!this.pinned.includes(key)) { diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue index 83157f7d59..e1927133ae 100644 --- a/src/client/components/note-detailed.vue +++ b/src/client/components/note-detailed.vue @@ -523,7 +523,7 @@ export default defineComponent({ react(viaKeyboard = false) { pleaseLogin(); this.blur(); - os.popup(import('@/components/emoji-picker.vue'), { + os.popup(import('@/components/emoji-picker-dialog.vue'), { src: this.$refs.reactButton, asReactionPicker: true }, { diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 377e5991b2..6af0668e2e 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -498,7 +498,7 @@ export default defineComponent({ react(viaKeyboard = false) { pleaseLogin(); this.blur(); - os.popup(import('@/components/emoji-picker.vue'), { + os.popup(import('@/components/emoji-picker-dialog.vue'), { src: this.$refs.reactButton, asReactionPicker: true }, { diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index 7849095ba8..9b5adf9cc1 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -606,9 +606,7 @@ export default defineComponent({ }, async insertEmoji(ev) { - os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { - insertTextAtCursor(this.$refs.text, emoji); - }); + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); }, showActions(ev) { diff --git a/src/client/components/ui/window.vue b/src/client/components/ui/window.vue index d8852b9ffd..4d3b048c11 100644 --- a/src/client/components/ui/window.vue +++ b/src/client/components/ui/window.vue @@ -1,8 +1,8 @@ <template> <transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> - <div class="ebkgocck" v-if="showing"> + <div class="ebkgocck" :class="{ front }" v-if="showing"> <div class="body _popup _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> - <div class="header" @contextmenu.prevent.stop="onContextmenu"> + <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> <slot v-if="closeRight" name="buttons"><button class="_button" style="pointer-events: none;"></button></slot> <button v-else class="_button" @click="close()"><Fa :icon="faTimes"/></button> @@ -92,6 +92,16 @@ export default defineComponent({ required: false, default: false, }, + mini: { + type: Boolean, + required: false, + default: false, + }, + front: { + type: Boolean, + required: false, + default: false, + }, contextmenu: { type: Array, required: false, @@ -387,6 +397,10 @@ export default defineComponent({ left: 0; z-index: 5000; + &.front { + z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない + } + > .body { overflow: hidden; // overflow: clip; をSafariが対応したら消す overflow: clip; @@ -397,17 +411,22 @@ export default defineComponent({ height: 100%; > .header { - $height: 50px; + --height: 50px; + + &.mini { + --height: 38px; + } + display: flex; position: relative; z-index: 1; flex-shrink: 0; user-select: none; - height: $height; + height: var(--height); > ::v-deep(button) { - height: $height; - width: $height; + height: var(--height); + width: var(--height); &:hover { color: var(--fgHighlighted); @@ -417,7 +436,7 @@ export default defineComponent({ > .title { flex: 1; position: relative; - line-height: $height; + line-height: var(--height); white-space: nowrap; overflow: hidden; // overflow: clip; をSafariが対応したら消す overflow: clip; diff --git a/src/client/os.ts b/src/client/os.ts index 59ee3617e1..fe4054e272 100644 --- a/src/client/os.ts +++ b/src/client/os.ts @@ -1,5 +1,8 @@ +// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する + import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue'; import { EventEmitter } from 'eventemitter3'; +import insertTextAtCursor from 'insert-text-at-cursor'; import * as Sentry from '@sentry/browser'; import Stream from '@/scripts/stream'; import { apiUrl, debug } from '@/config'; @@ -289,7 +292,7 @@ export async function selectDriveFolder(multiple: boolean) { export async function pickEmoji(src?: HTMLElement, opts) { return new Promise((resolve, reject) => { - popup(import('@/components/emoji-picker.vue'), { + popup(import('@/components/emoji-picker-dialog.vue'), { src, ...opts }, { @@ -300,6 +303,63 @@ export async function pickEmoji(src?: HTMLElement, opts) { }); } +type AwaitType<T> = + T extends Promise<infer U> ? U : + T extends (...args: Array<any>) => Promise<infer V> ? V : + T; +let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null; +let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; +export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { + if (openingEmojiPicker) return; + + activeTextarea = initialTextarea; + + const textareas = document.querySelectorAll('textarea, input'); + for (const textarea of Array.from(textareas)) { + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + + const observer = new MutationObserver(records => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if (node instanceof HTMLElement) { + const textareas = node.querySelectorAll('textarea, input'); + for (const textarea of Array.from(textareas)) { + if (textarea.dataset.preventEmojiInsert != null) return; + if (document.activeElement === textarea) activeTextarea = textarea; + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + } + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); + + openingEmojiPicker = await popup(import('@/components/emoji-picker-window.vue'), { + src, + ...opts + }, { + chosen: emoji => { + insertTextAtCursor(activeTextarea, emoji); + }, + closed: () => { + openingEmojiPicker!.dispose(); + openingEmojiPicker = null; + observer.disconnect(); + } + }); +} + export function modalMenu(items: any[], src?: HTMLElement, options?: { align?: string; viaKeyboard?: boolean }) { return new Promise((resolve, reject) => { let dispose; diff --git a/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue index 258300dc52..5e675a3f12 100644 --- a/src/client/pages/messaging/messaging-room.form.vue +++ b/src/client/pages/messaging/messaging-room.form.vue @@ -223,9 +223,7 @@ export default defineComponent({ }, async insertEmoji(ev) { - os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { - insertTextAtCursor(this.$refs.text, emoji); - }); + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); } } }); diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue index 798ffb33d1..4488e50051 100644 --- a/src/client/pages/settings/reaction.vue +++ b/src/client/pages/settings/reaction.vue @@ -105,7 +105,7 @@ export default defineComponent({ }, preview(ev) { - os.popup(import('@/components/emoji-picker.vue'), { + os.popup(import('@/components/emoji-picker-dialog.vue'), { asReactionPicker: true, src: ev.currentTarget || ev.target, }, {}, 'closed'); diff --git a/src/client/style.scss b/src/client/style.scss index 3ebb5fabd1..aa2face04e 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -391,8 +391,8 @@ hr { ._acrylic { background: var(--acrylicPanel); - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(15px); + backdrop-filter: blur(15px); } ._vMargin { diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue index 3c5c1acfdc..d80978e182 100644 --- a/src/client/ui/chat/note.vue +++ b/src/client/ui/chat/note.vue @@ -504,7 +504,7 @@ export default defineComponent({ pleaseLogin(); this.operating = true; this.blur(); - const { dispose } = await os.popup(import('@/components/emoji-picker.vue'), { + const { dispose } = await os.popup(import('@/components/emoji-picker-dialog.vue'), { src: this.$refs.reactButton, asReactionPicker: true }, { diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue index b0a31b097d..9388989d5d 100644 --- a/src/client/ui/chat/post-form.vue +++ b/src/client/ui/chat/post-form.vue @@ -593,9 +593,7 @@ export default defineComponent({ }, async insertEmoji(ev) { - os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { - insertTextAtCursor(this.$refs.text, emoji); - }); + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); }, showActions(ev) {