絵文字ピッカーを強化 + 絵文字ピッカーをリアクションピッカーとして使えるように

Resolve #5079
Resolve #3219
This commit is contained in:
syuilo 2020-11-07 10:43:27 +09:00
parent 11308db231
commit 48bf0b66b7
5 changed files with 369 additions and 154 deletions

View file

@ -542,6 +542,7 @@ pluginInstallWarn: "信頼できないプラグインはインストールしな
deck: "デッキ" deck: "デッキ"
undeck: "デッキ解除" undeck: "デッキ解除"
useBlurEffectForModal: "モーダルにぼかし効果を使用" useBlurEffectForModal: "モーダルにぼかし効果を使用"
useFullReactionPicker: "フル機能リアクションピッカーを使用"
generateAccessToken: "アクセストークンの発行" generateAccessToken: "アクセストークンの発行"
permission: "権限" permission: "権限"
enableAll: "全て有効にする" enableAll: "全て有効にする"

View file

@ -1,62 +1,94 @@
<template> <template>
<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> <MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')">
<div class="omfetrab _popup"> <div class="omfetrab _popup">
<header> <input ref="search" class="search" v-model.trim="q" :placeholder="$t('search')" @paste.stop="paste" @keyup.enter="done()" autofocus>
<button v-for="(category, i) in categories"
class="_button"
@click="go(category)"
:class="{ active: category.isActive }"
:key="i"
>
<Fa :icon="category.icon" fixed-width/>
</button>
</header>
<div class="emojis"> <div class="emojis">
<template v-if="categories[0].isActive"> <section class="result">
<header class="category"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header> <div v-if="searchResultCustom.length > 0">
<div class="list"> <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.device.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>
<div>
<button v-for="emoji in reactions || $store.state.settings.reactions"
class="_button"
:title="emoji.name"
@click="chosen(emoji, $event)"
:key="emoji"
tabindex="0"
>
<MkEmoji :emoji="emoji.startsWith(':') ? null : emoji" :name="emoji.startsWith(':') ? emoji.substr(1, emoji.length - 2) : null" :normal="true"/>
</button>
</div>
</section>
<section>
<header class="_acrylic"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header>
<div>
<button v-for="emoji in ($store.state.device.recentEmojis || [])" <button v-for="emoji in ($store.state.device.recentEmojis || [])"
class="_button" class="_button"
:title="emoji.name" :title="emoji.name"
@click="chosen(emoji)" @click="chosen(emoji, $event)"
:key="emoji" :key="emoji"
> >
<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>
<img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
</button> </button>
</div> </div>
</section>
<header class="category"><Fa :icon="faAsterisk" fixed-width/> {{ $t('customEmojis') }}</header> <div class="arrow"><Fa :icon="faChevronDown"/></div>
</template>
<template v-if="categories.find(x => x.isActive).name">
<div class="list">
<button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)"
class="_button"
:title="emoji.name"
@click="chosen(emoji)"
:key="emoji.name"
>
<MkEmoji :emoji="emoji.char"/>
</button>
</div> </div>
</template>
<template v-else> <section v-for="category in customEmojiCategories" :key="'custom:' + category" class="custom">
<div v-for="(key, i) in Object.keys(customEmojis)" :key="i"> <header class="_acrylic" v-appear="() => visibleCategories[category] = true">{{ category || $t('other') }}</header>
<header class="sub" v-if="key">{{ key }}</header> <div v-if="visibleCategories[category]">
<div class="list"> <button v-for="emoji in customEmojis.filter(e => e.category === category)"
<button v-for="emoji in customEmojis[key]"
class="_button" class="_button"
:title="emoji.name" :title="emoji.name"
@click="chosen(emoji)" @click="chosen(emoji, $event)"
:key="emoji.name" :key="emoji.name"
> >
<img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
</button> </button>
</div> </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> </div>
</template> </section>
</div> </div>
</div> </div>
</MkModal> </MkModal>
@ -66,10 +98,11 @@
import { defineComponent, markRaw } from 'vue'; import { defineComponent, markRaw } from 'vue';
import { emojilist } from '../../misc/emojilist'; 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, faHistory, faUser } from '@fortawesome/free-solid-svg-icons'; import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory, 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 { groupByX } from '../../prelude/array';
import MkModal from '@/components/ui/modal.vue'; import MkModal from '@/components/ui/modal.vue';
import Particle from '@/components/particle.vue';
import * as os from '@/os';
export default defineComponent({ export default defineComponent({
components: { components: {
@ -80,6 +113,9 @@ export default defineComponent({
src: { src: {
required: false required: false
}, },
reactions: {
required: false
},
}, },
emits: ['done', 'closed'], emits: ['done', 'closed'],
@ -88,12 +124,14 @@ export default defineComponent({
return { return {
emojilist: markRaw(emojilist), emojilist: markRaw(emojilist),
getStaticImageUrl, getStaticImageUrl,
customEmojis: {}, customEmojiCategories: this.$store.getters['instance/emojiCategories'],
faGlobe, faHistory, customEmojis: this.$store.state.instance.meta.emojis,
visibleCategories: {},
q: null,
searchResultCustom: [],
searchResultUnicode: [],
faGlobe, faHistory, faChevronDown,
categories: [{ categories: [{
icon: faAsterisk,
isActive: true
}, {
name: 'face', name: 'face',
icon: faLaugh, icon: faLaugh,
isActive: false isActive: false
@ -134,38 +172,149 @@ export default defineComponent({
}; };
}, },
created() { watch: {
let local = this.$store.state.instance.meta.emojis; q() {
local = groupByX(local, (x: any) => x.category || ''); if (this.q == null || this.q === '') {
this.customEmojis = markRaw(local); this.searchResultCustom = [];
this.searchResultUnicode = [];
return;
}
const q = this.q.replace(/:/g, '');
const searchCustom = () => {
const max = 8;
const emojis = this.customEmojis;
const matches = new Set();
const exactMatch = emojis.find(e => e.name === q);
if (exactMatch) matches.add(exactMatch);
for (const emoji of emojis) {
if (emoji.name.startsWith(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.startsWith(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.includes(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
return matches;
};
const searchUnicode = () => {
const max = 8;
const emojis = this.emojilist;
const matches = new Set();
const exactMatch = emojis.find(e => e.name === q);
if (exactMatch) matches.add(exactMatch);
for (const emoji of emojis) {
if (emoji.name.startsWith(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.startsWith(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.includes(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
return matches;
};
this.searchResultCustom = Array.from(searchCustom());
this.searchResultUnicode = Array.from(searchUnicode());
}
},
mounted() {
this.$refs.search.focus();
}, },
methods: { methods: {
go(category: any) { chosen(emoji: any, ev) {
this.goCategory(category.name); if (ev) {
}, const el = ev.currentTarget || ev.target;
const rect = el.getBoundingClientRect();
const x = rect.left + (el.clientWidth / 2);
const y = rect.top + (el.clientHeight / 2);
os.popup(Particle, { x, y }, {}, 'end');
}
goCategory(name: string) { const getKey = (emoji: any) => typeof emoji === 'string' ? emoji : emoji.char || `:${emoji.name}:`;
let matched = false; this.$emit('done', getKey(emoji));
for (const c of this.categories) { this.$refs.modal.close();
c.isActive = c.name === name;
if (c.isActive) {
matched = true;
}
}
if (!matched) {
this.categories[0].isActive = true;
}
},
chosen(emoji: any) { // 使
const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`;
let recents = this.$store.state.device.recentEmojis || []; let recents = this.$store.state.device.recentEmojis || [];
recents = recents.filter((e: any) => getKey(e) !== getKey(emoji)); recents = recents.filter((e: any) => getKey(e) !== getKey(emoji));
recents.unshift(emoji) recents.unshift(emoji)
this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) }); this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) });
this.$emit('done', getKey(emoji)); },
this.$refs.modal.close();
paste(event) {
const paste = (event.clipboardData || window.clipboardData).getData('text');
if (this.done(paste)) {
event.preventDefault();
}
},
done(query) {
if (query == null) query = this.q;
if (query == null) return;
const q = query.replace(/:/g, '');
const exactMatchCustom = this.customEmojis.find(e => e.name === q);
if (exactMatchCustom) {
this.chosen(exactMatchCustom);
return true;
}
const exactMatchUnicode = this.emojilist.find(e => e.name === q);
if (exactMatchUnicode) {
this.chosen(exactMatchUnicode);
return true;
}
}, },
} }
}); });
@ -174,49 +323,54 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.omfetrab { .omfetrab {
width: 350px; width: 350px;
contain: content;
> header { > .search {
display: flex; width: 100%;
padding: 12px;
> button { box-sizing: border-box;
flex: 1; font-size: 1em;
padding: 10px 0; outline: none;
font-size: 16px; border: none;
transition: color 0.2s ease; background: transparent;
color: var(--fg);
&:hover {
color: var(--fgHighlighted);
transition: color 0s;
}
&.active {
color: var(--accent);
transition: color 0s;
}
}
} }
> .emojis { > .emojis {
height: 300px; $height: 300px;
height: $height;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
> header.category { > .index {
min-height: $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; position: sticky;
top: 0; top: 0;
left: 0; left: 0;
z-index: 1; z-index: 1;
padding: 8px; padding: 8px;
background: var(--panel);
font-size: 12px; font-size: 12px;
} }
header.sub { > div {
padding: 4px 8px;
font-size: 12px;
}
div.list {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
gap: 4px; gap: 4px;
@ -227,6 +381,11 @@ export default defineComponent({
padding: 0; padding: 0;
width: 100%; width: 100%;
&:focus {
outline: solid 2px var(--focus);
z-index: 1;
}
&:before { &:before {
content: ''; content: '';
display: block; display: block;
@ -255,6 +414,19 @@ export default defineComponent({
} }
} }
} }
&.result {
border-bottom: solid 1px var(--divider);
}
&.unicode {
min-height: 384px;
}
&.custom {
min-height: 64px;
}
}
} }
} }
</style> </style>

View file

@ -498,6 +498,21 @@ export default defineComponent({
react(viaKeyboard = false) { react(viaKeyboard = false) {
pleaseLogin(); pleaseLogin();
this.blur(); this.blur();
if (this.$store.state.device.useFullReactionPicker) {
os.popup(import('@/components/emoji-picker.vue'), {
src: this.$refs.reactButton,
}, {
done: reaction => {
if (reaction) {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
}
this.focus();
},
}, 'closed');
} else {
os.popup(import('@/components/reaction-picker.vue'), { os.popup(import('@/components/reaction-picker.vue'), {
showFocus: viaKeyboard, showFocus: viaKeyboard,
src: this.$refs.reactButton, src: this.$refs.reactButton,
@ -512,6 +527,7 @@ export default defineComponent({
this.focus(); this.focus();
}, },
}, 'closed'); }, 'closed');
}
}, },
reactDirectly(reaction) { reactDirectly(reaction) {

View file

@ -7,6 +7,7 @@
{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template> {{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template>
</MkInput> </MkInput>
<MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton> <MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton>
<MkSwitch v-model:value="useFullReactionPicker">{{ $t('useFullReactionPicker') }}</MkSwitch>
</div> </div>
<div class="_footer"> <div class="_footer">
<MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
@ -22,6 +23,7 @@ import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
import { faUndo } from '@fortawesome/free-solid-svg-icons'; import { faUndo } from '@fortawesome/free-solid-svg-icons';
import MkInput from '@/components/ui/input.vue'; import MkInput from '@/components/ui/input.vue';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import MkSwitch from '@/components/ui/switch.vue';
import { emojiRegexWithCustom } from '../../../misc/emoji-regex'; import { emojiRegexWithCustom } from '../../../misc/emoji-regex';
import { defaultSettings } from '@/store'; import { defaultSettings } from '@/store';
import * as os from '@/os'; import * as os from '@/os';
@ -30,6 +32,7 @@ export default defineComponent({
components: { components: {
MkInput, MkInput,
MkButton, MkButton,
MkSwitch,
}, },
emits: ['info'], emits: ['info'],
@ -50,6 +53,11 @@ export default defineComponent({
splited(): any { splited(): any {
return this.reactions.match(emojiRegexWithCustom); return this.reactions.match(emojiRegexWithCustom);
}, },
useFullReactionPicker: {
get() { return this.$store.state.device.useFullReactionPicker; },
set(value) { this.$store.commit('device/set', { key: 'useFullReactionPicker', value: value }); }
},
}, },
watch: { watch: {
@ -72,11 +80,18 @@ export default defineComponent({
}, },
preview(ev) { preview(ev) {
if (this.$store.state.device.useFullReactionPicker) {
os.popup(import('@/components/emoji-picker.vue'), {
reactions: this.splited,
src: ev.currentTarget || ev.target,
}, {}, 'closed');
} else {
os.popup(import('@/components/reaction-picker.vue'), { os.popup(import('@/components/reaction-picker.vue'), {
reactions: this.splited, reactions: this.splited,
showFocus: false, showFocus: false,
src: ev.currentTarget || ev.target, src: ev.currentTarget || ev.target,
}, {}, 'closed'); }, {}, 'closed');
}
}, },
setDefault() { setDefault() {

View file

@ -76,6 +76,7 @@ export const defaultDeviceSettings = {
disablePagesScript: false, disablePagesScript: false,
enableInfiniteScroll: true, enableInfiniteScroll: true,
useBlurEffectForModal: true, useBlurEffectForModal: true,
useFullReactionPicker: false,
sidebarDisplay: 'full', // full, icon, hide sidebarDisplay: 'full', // full, icon, hide
instanceTicker: 'remote', // none, remote, always instanceTicker: 'remote', // none, remote, always
roomGraphicsQuality: 'medium', roomGraphicsQuality: 'medium',
@ -182,6 +183,16 @@ export const store = createStore({
meta: null meta: null
}, },
getters: {
emojiCategories: state => {
const categories = new Set();
for (const emoji of state.meta.emojis) {
categories.add(emoji.category);
}
return Array.from(categories);
},
},
mutations: { mutations: {
set(state, meta) { set(state, meta) {
state.meta = meta; state.meta = meta;