絵文字ピッカーを常に表示するように

Resolve #7265
This commit is contained in:
syuilo 2021-02-27 13:08:34 +09:00
parent f2e071baaa
commit f29d417b30
13 changed files with 563 additions and 113 deletions

View file

@ -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>

View file

@ -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>

View file

@ -1,93 +1,91 @@
<template> <template>
<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> <div class="omfetrab _popup" :class="['w' + width, 'h' + height, { big }]">
<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()">
<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">
<div class="emojis" ref="emojis"> <section class="result">
<section class="result"> <div v-if="searchResultCustom.length > 0">
<div v-if="searchResultCustom.length > 0"> <button v-for="emoji in searchResultCustom"
<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" class="_button"
:title="emoji.name"
@click="chosen(emoji, $event)" @click="chosen(emoji, $event)"
:key="emoji" :key="emoji"
tabindex="0"
> >
<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> <MkEmoji :emoji="emoji" :normal="true"/>
<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> </button>
</div> </div>
</section> </section>
<div class="index"> <div class="arrow"><Fa :icon="faChevronDown"/></div>
<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> </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> </div>
</MkModal> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -96,7 +94,6 @@ import { emojilist } from '../../misc/emojilist';
import { getStaticImageUrl } from '@/scripts/get-static-image-url'; 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 { 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 { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons';
import MkModal from '@/components/ui/modal.vue';
import Particle from '@/components/particle.vue'; import Particle from '@/components/particle.vue';
import * as os from '@/os'; import * as os from '@/os';
import { isDeviceTouch } from '@/scripts/is-device-touch'; import { isDeviceTouch } from '@/scripts/is-device-touch';
@ -104,14 +101,7 @@ import { isMobile } from '@/scripts/is-mobile';
import { emojiCategories } from '@/instance'; import { emojiCategories } from '@/instance';
export default defineComponent({ export default defineComponent({
components: {
MkModal,
},
props: { props: {
src: {
required: false
},
showPinned: { showPinned: {
required: false, required: false,
default: true default: true
@ -121,7 +111,7 @@ export default defineComponent({
}, },
}, },
emits: ['done', 'closed'], emits: ['chosen'],
data() { data() {
return { return {
@ -345,8 +335,7 @@ export default defineComponent({
} }
const key = this.getKey(emoji); const key = this.getKey(emoji);
this.$emit('done', key); this.$emit('chosen', key);
this.$refs.modal.close();
// 使 // 使
if (!this.pinned.includes(key)) { if (!this.pinned.includes(key)) {

View file

@ -523,7 +523,7 @@ export default defineComponent({
react(viaKeyboard = false) { react(viaKeyboard = false) {
pleaseLogin(); pleaseLogin();
this.blur(); this.blur();
os.popup(import('@/components/emoji-picker.vue'), { os.popup(import('@/components/emoji-picker-dialog.vue'), {
src: this.$refs.reactButton, src: this.$refs.reactButton,
asReactionPicker: true asReactionPicker: true
}, { }, {

View file

@ -498,7 +498,7 @@ export default defineComponent({
react(viaKeyboard = false) { react(viaKeyboard = false) {
pleaseLogin(); pleaseLogin();
this.blur(); this.blur();
os.popup(import('@/components/emoji-picker.vue'), { os.popup(import('@/components/emoji-picker-dialog.vue'), {
src: this.$refs.reactButton, src: this.$refs.reactButton,
asReactionPicker: true asReactionPicker: true
}, { }, {

View file

@ -606,9 +606,7 @@ export default defineComponent({
}, },
async insertEmoji(ev) { async insertEmoji(ev) {
os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
insertTextAtCursor(this.$refs.text, emoji);
});
}, },
showActions(ev) { showActions(ev) {

View file

@ -1,8 +1,8 @@
<template> <template>
<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> <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="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> <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> <button v-else class="_button" @click="close()"><Fa :icon="faTimes"/></button>
@ -92,6 +92,16 @@ export default defineComponent({
required: false, required: false,
default: false, default: false,
}, },
mini: {
type: Boolean,
required: false,
default: false,
},
front: {
type: Boolean,
required: false,
default: false,
},
contextmenu: { contextmenu: {
type: Array, type: Array,
required: false, required: false,
@ -387,6 +397,10 @@ export default defineComponent({
left: 0; left: 0;
z-index: 5000; z-index: 5000;
&.front {
z-index: 11000; // frontmk-modal
}
> .body { > .body {
overflow: hidden; // overflow: clip; Safari overflow: hidden; // overflow: clip; Safari
overflow: clip; overflow: clip;
@ -397,17 +411,22 @@ export default defineComponent({
height: 100%; height: 100%;
> .header { > .header {
$height: 50px; --height: 50px;
&.mini {
--height: 38px;
}
display: flex; display: flex;
position: relative; position: relative;
z-index: 1; z-index: 1;
flex-shrink: 0; flex-shrink: 0;
user-select: none; user-select: none;
height: $height; height: var(--height);
> ::v-deep(button) { > ::v-deep(button) {
height: $height; height: var(--height);
width: $height; width: var(--height);
&:hover { &:hover {
color: var(--fgHighlighted); color: var(--fgHighlighted);
@ -417,7 +436,7 @@ export default defineComponent({
> .title { > .title {
flex: 1; flex: 1;
position: relative; position: relative;
line-height: $height; line-height: var(--height);
white-space: nowrap; white-space: nowrap;
overflow: hidden; // overflow: clip; Safari overflow: hidden; // overflow: clip; Safari
overflow: clip; overflow: clip;

View file

@ -1,5 +1,8 @@
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue'; import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import insertTextAtCursor from 'insert-text-at-cursor';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import Stream from '@/scripts/stream'; import Stream from '@/scripts/stream';
import { apiUrl, debug } from '@/config'; import { apiUrl, debug } from '@/config';
@ -289,7 +292,7 @@ export async function selectDriveFolder(multiple: boolean) {
export async function pickEmoji(src?: HTMLElement, opts) { export async function pickEmoji(src?: HTMLElement, opts) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(import('@/components/emoji-picker.vue'), { popup(import('@/components/emoji-picker-dialog.vue'), {
src, src,
...opts ...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 }) { export function modalMenu(items: any[], src?: HTMLElement, options?: { align?: string; viaKeyboard?: boolean }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let dispose; let dispose;

View file

@ -223,9 +223,7 @@ export default defineComponent({
}, },
async insertEmoji(ev) { async insertEmoji(ev) {
os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
insertTextAtCursor(this.$refs.text, emoji);
});
} }
} }
}); });

View file

@ -105,7 +105,7 @@ export default defineComponent({
}, },
preview(ev) { preview(ev) {
os.popup(import('@/components/emoji-picker.vue'), { os.popup(import('@/components/emoji-picker-dialog.vue'), {
asReactionPicker: true, asReactionPicker: true,
src: ev.currentTarget || ev.target, src: ev.currentTarget || ev.target,
}, {}, 'closed'); }, {}, 'closed');

View file

@ -391,8 +391,8 @@ hr {
._acrylic { ._acrylic {
background: var(--acrylicPanel); background: var(--acrylicPanel);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(10px); backdrop-filter: blur(15px);
} }
._vMargin { ._vMargin {

View file

@ -504,7 +504,7 @@ export default defineComponent({
pleaseLogin(); pleaseLogin();
this.operating = true; this.operating = true;
this.blur(); 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, src: this.$refs.reactButton,
asReactionPicker: true asReactionPicker: true
}, { }, {

View file

@ -593,9 +593,7 @@ export default defineComponent({
}, },
async insertEmoji(ev) { async insertEmoji(ev) {
os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
insertTextAtCursor(this.$refs.text, emoji);
});
}, },
showActions(ev) { showActions(ev) {