絵文字デッキの作成

This commit is contained in:
samunohito 2023-12-09 13:02:01 +09:00
parent b72f9186b5
commit 90c735f234
10 changed files with 254 additions and 107 deletions

9
locales/index.d.ts vendored
View File

@ -124,7 +124,14 @@ export interface Locale {
"add": string;
"reaction": string;
"reactions": string;
"reactionSetting": string;
"reactionDeckSettingTitle": string;
"reactionDeckSettingDescription": string;
"emojiDeckSettingTitle": string;
"emojiDeckSettingDescription": string;
"diversionReactionDeckEmojisTitle": string;
"diversionReactionDeckEmojisDescription": string;
"diversionReactionDeckSettingCaption": string;
"diversionReactionDeckSettingDescription": string;
"reactionSettingDescription2": string;
"rememberNoteVisibility": string;
"attachCancel": string;

View File

@ -121,7 +121,14 @@ sensitive: "センシティブ"
add: "追加"
reaction: "リアクション"
reactions: "リアクション"
reactionSetting: "ピッカーに表示するリアクション"
reactionDeckSettingTitle: "リアクションデッキ"
reactionDeckSettingDescription: "ノートの+ボタンから使用するリアクションデッキの設定です。"
emojiDeckSettingTitle: "絵文字デッキ"
emojiDeckSettingDescription: "投稿時に使用する絵文字デッキの設定です。"
diversionReactionDeckEmojisTitle: "ピッカーの表示設定"
diversionReactionDeckEmojisDescription: "ピッカーの表示についての設定を行います。この設定はリアクションピッカーと絵文字ピッカーで共通です。"
diversionReactionDeckSettingCaption: "リアクションデッキと同じ設定を使う"
diversionReactionDeckSettingDescription: "絵文字デッキの設定をリアクションデッキと同じにします。絵文字デッキの設定内容そのものは消えません。"
reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。"
rememberNoteVisibility: "公開範囲を記憶する"
attachCancel: "添付取り消し"

View File

@ -77,8 +77,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="`custom:${child.value}`"
:initialShown="false"
:emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
:hasChildSection="child.children.length !== 0"
:customEmojiTree="child.children"
@chosen="chosen"
>
{{ child.value || i18n.ts.other }}
@ -103,12 +103,12 @@ import { ref, shallowRef, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import XSection from '@/components/MkEmojiPicker.section.vue';
import {
emojilist,
emojiCharByCategory,
UnicodeEmojiDef,
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree
emojilist,
emojiCharByCategory,
UnicodeEmojiDef,
unicodeEmojiCategories as categories,
getEmojiName,
CustomEmojiFolderTree,
} from '@/scripts/emojilist.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
@ -121,6 +121,7 @@ import { $i } from '@/account.js';
const props = withDefaults(defineProps<{
showPinned?: boolean;
pinnedEmojis?: string[];
asReactionPicker?: boolean;
maxHeight?: number;
asDrawer?: boolean;
@ -137,15 +138,13 @@ const searchEl = shallowRef<HTMLInputElement>();
const emojisEl = shallowRef<HTMLDivElement>();
const {
reactions: pinnedReactions,
reactionPickerSize,
reactionPickerWidth,
reactionPickerHeight,
disableShowingAnimatedImages,
recentlyUsedEmojis,
} = defaultStore.reactiveState;
const pinned = computed(() => props.asReactionPicker ? pinnedReactions.value : []); // TODO: pinned
const pinned = computed(() => props.pinnedEmojis);
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
@ -154,7 +153,7 @@ const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
const customEmojiFolderRoot: CustomEmojiFolderTree = { value: "", category: "", children: [] };
const customEmojiFolderRoot: CustomEmojiFolderTree = { value: '', category: '', children: [] };
function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree {
const parts = input.split('/').map(p => p.trim());
@ -176,9 +175,9 @@ function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): Cu
}
customEmojiCategories.value.forEach(ec => {
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
}
if (ec !== null) {
parseAndMergeCategories(ec, customEmojiFolderRoot);
}
});
parseAndMergeCategories('', customEmojiFolderRoot);

View File

@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_popup _shadow"
:class="{ [$style.drawer]: type === 'drawer' }"
:showPinned="showPinned"
:pinnedEmojis="pinnedEmojis"
:asReactionPicker="asReactionPicker"
:asDrawer="type === 'drawer'"
:max-height="maxHeight"
@ -40,11 +41,13 @@ const props = withDefaults(defineProps<{
manualShowing?: boolean | null;
src?: HTMLElement;
showPinned?: boolean;
pinnedEmojis?: string[],
asReactionPicker?: boolean;
choseAndClose?: boolean;
}>(), {
manualShowing: null,
showPinned: true,
pinnedEmojis: undefined,
asReactionPicker: false,
choseAndClose: true,
});

View File

@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="[$style.root, { [$style.rootFirst]: first }]">
<div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
<div :class="[$style.description]"><slot name="description"></slot></div>
<div :class="$style.main">
<slot></slot>
</div>
@ -31,7 +32,7 @@ defineProps<{
.label {
font-weight: bold;
padding: 1.5em 0 0 0;
margin: 0 0 16px 0;
margin: 0 0 8px 0;
&:empty {
display: none;
@ -45,4 +46,10 @@ defineProps<{
.main {
margin: 1.5em 0 0 0;
}
.description {
font-size: 0.85em;
color: var(--fgTransparentWeak);
margin: 0 0 8px 0;
}
</style>

View File

@ -95,6 +95,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'numberOfPageCache',
'showNoteActionsOnlyHover',
'showClipButtonInNoteFooter',
'useReactionDeckItems',
'reactionsDisplaySize',
'forceShowAds',
'aiChanMode',

View File

@ -5,65 +5,129 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps_m">
<FromSlot>
<template #label>{{ i18n.ts.reactionSettingDescription }}</template>
<div v-panel style="border-radius: 6px;">
<Sortable v-model="reactions" :class="$style.reactions" :itemKey="item => item" :animation="150" :delay="100" :delayOnTouchOnly="true">
<template #item="{element}">
<button class="_button" :class="$style.reactionsItem" @click="remove(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji"><i class="ti ti-plus"></i></button>
</template>
</Sortable>
<FormSection first="true">
<template #label>{{ i18n.ts.reactionDeckSettingTitle }}</template>
<template #description>{{ i18n.ts.reactionDeckSettingDescription }}</template>
<div class="_gaps_m">
<div>
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="reactionDeckItems"
:class="$style.reactions"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.reactionsItem" @click="removeReaction(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.reactionsAdd" @click="chooseReaction">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<span class="description">{{ i18n.ts.reactionSettingDescription2 }}</span>
</div>
<div class="_buttons">
<MkButton inline @click="previewReaction"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultReaction"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
</div>
</div>
<template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template>
</FromSlot>
<MkRadios v-model="reactionPickerSize">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="reactionPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="reactionPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="reactionPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
</FormSection>
<FormSection>
<div class="_buttons">
<MkButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<template #label>{{ i18n.ts.emojiDeckSettingTitle }}</template>
<template #description>{{ i18n.ts.emojiDeckSettingDescription }}</template>
<div class="_gaps_m">
<MkSwitch v-model="useReactionDeckItems">
{{ i18n.ts.diversionReactionDeckSettingCaption }}
<template #caption>{{ i18n.ts.diversionReactionDeckSettingDescription }}</template>
</MkSwitch>
<div v-if="!useReactionDeckItems">
<div v-panel style="border-radius: 6px;">
<Sortable
v-model="emojiDeckItems"
:class="$style.reactions"
:itemKey="item => item"
:animation="150"
:delay="100"
:delayOnTouchOnly="true"
>
<template #item="{element}">
<button class="_button" :class="$style.reactionsItem" @click="removeEmoji(element, $event)">
<MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true"/>
<MkEmoji v-else :emoji="element" :normal="true"/>
</button>
</template>
<template #footer>
<button class="_button" :class="$style.reactionsAdd" @click="chooseEmoji">
<i class="ti ti-plus"></i>
</button>
</template>
</Sortable>
</div>
<span class="description">{{ i18n.ts.reactionSettingDescription2 }}</span>
</div>
<div v-if="!useReactionDeckItems" class="_buttons">
<MkButton inline @click="previewEmoji"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</MkButton>
<MkButton inline danger @click="setDefaultEmoji"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
</div>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.diversionReactionDeckEmojisTitle }}</template>
<template #description>{{ i18n.ts.diversionReactionDeckEmojisDescription }}</template>
<div class="_gaps_m">
<MkRadios v-model="reactionPickerSize">
<template #label>{{ i18n.ts.size }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
</MkRadios>
<MkRadios v-model="reactionPickerWidth">
<template #label>{{ i18n.ts.numberOfColumn }}</template>
<option :value="1">5</option>
<option :value="2">6</option>
<option :value="3">7</option>
<option :value="4">8</option>
<option :value="5">9</option>
</MkRadios>
<MkRadios v-model="reactionPickerHeight">
<template #label>{{ i18n.ts.height }}</template>
<option :value="1">{{ i18n.ts.small }}</option>
<option :value="2">{{ i18n.ts.medium }}</option>
<option :value="3">{{ i18n.ts.large }}</option>
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="reactionPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
</div>
</FormSection>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, watch, ref, computed } from 'vue';
import { computed, ref, Ref, watch } from 'vue';
import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import FromSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
@ -72,88 +136,110 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue';
import MkEmoji from '@/components/global/MkEmoji.vue';
const reactions = ref(deepClone(defaultStore.state.reactions));
const reactionDeckItems: Ref<string[]> = ref(deepClone(defaultStore.state.reactions));
const emojiDeckItems: Ref<string[]> = ref(deepClone(defaultStore.state.emojiDeckItems));
const useReactionDeckItems = computed(defaultStore.makeGetterSetter('useReactionDeckItems'));
const reactionPickerSize = computed(defaultStore.makeGetterSetter('reactionPickerSize'));
const reactionPickerWidth = computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
const reactionPickerHeight = computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
const reactionPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
function save() {
defaultStore.set('reactions', reactions.value);
const removeReaction = (reaction: string, ev: MouseEvent) => remove(reactionDeckItems, reaction, ev);
const chooseReaction = (ev: MouseEvent) => pickEmoji(reactionDeckItems, ev);
const setDefaultReaction = () => setDefault(reactionDeckItems);
const removeEmoji = (reaction: string, ev: MouseEvent) => remove(emojiDeckItems, reaction, ev);
const chooseEmoji = (ev: MouseEvent) => pickEmoji(emojiDeckItems, ev);
const setDefaultEmoji = () => setDefault(emojiDeckItems);
function previewReaction(ev: MouseEvent) {
reactionPicker.show(getHTMLElement(ev));
}
function remove(reaction, ev: MouseEvent) {
function previewEmoji(ev: MouseEvent) {
emojiPicker.show(getHTMLElement(ev), undefined, undefined, 'emojis');
}
function remove(itemsRef: Ref<string[]>, reaction: string, ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.remove,
action: () => {
reactions.value = reactions.value.filter(x => x !== reaction);
itemsRef.value = itemsRef.value.filter(x => x !== reaction);
},
}], ev.currentTarget ?? ev.target);
}], getHTMLElement(ev));
}
function preview(ev: MouseEvent) {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
asReactionPicker: true,
src: ev.currentTarget ?? ev.target,
}, {}, 'closed');
}
async function setDefault() {
async function setDefault(itemsRef: Ref<string[]>) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.resetAreYouSure,
});
if (canceled) return;
reactions.value = deepClone(defaultStore.def.reactions.default);
itemsRef.value = deepClone(defaultStore.def.reactions.default);
}
function chooseEmoji(ev: MouseEvent) {
os.pickEmoji(ev.currentTarget ?? ev.target, {
async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), {
showPinned: false,
}).then(emoji => {
if (!reactions.value.includes(emoji)) {
reactions.value.push(emoji);
}).then(it => {
const emoji = it as string;
if (!itemsRef.value.includes(emoji)) {
itemsRef.value.push(emoji);
}
});
}
watch(reactions, () => {
save();
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
watch(reactionDeckItems, () => {
defaultStore.set('reactions', reactionDeckItems.value);
}, {
deep: true,
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
watch(emojiDeckItems, () => {
defaultStore.set('emojiDeckItems', emojiDeckItems.value);
}, {
deep: true,
});
definePageMetadata({
title: i18n.ts.reaction,
icon: 'ti ti-mood-happy',
action: {
icon: 'ti ti-eye',
handler: preview,
},
});
</script>
<style lang="scss">
.description {
font-size: 0.85em;
color: var(--fgTransparentWeak);
}
</style>
<style lang="scss" module>
.reactions {
padding: 12px;
font-size: 1.1em;
padding: 12px;
font-size: 1.1em;
}
.reactionsItem {
display: inline-block;
padding: 8px;
cursor: move;
display: inline-block;
padding: 8px;
cursor: move;
}
.reactionsAdd {
display: inline-block;
padding: 8px;
display: inline-block;
padding: 8px;
}
</style>

View File

@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent, Ref, ref } from 'vue';
import { defineAsyncComponent, Ref, ref, computed, ComputedRef } from 'vue';
import { popup } from '@/os.js';
import { defaultStore } from '@/store.js';
/**
*
@ -15,6 +16,7 @@ import { popup } from '@/os.js';
class EmojiPicker {
private src: Ref<HTMLElement | null> = ref(null);
private manualShowing = ref(false);
private itemPresetType = ref<DeckItemPresetType>('auto');
private onChosen?: (emoji: string) => void;
private onClosed?: () => void;
@ -22,10 +24,30 @@ class EmojiPicker {
// nop
}
private createDeckItemCompute(): ComputedRef<string[]> {
const itemPresetType = this.itemPresetType;
const useReactionDeckItems = defaultStore.reactiveState.useReactionDeckItems;
const reactionsRef = defaultStore.reactiveState.reactions;
const emojisRef = defaultStore.reactiveState.emojiDeckItems;
return computed(() => {
switch (itemPresetType.value) {
case 'reactions':
return reactionsRef.value;
case 'emojis':
return emojisRef.value;
default:
return useReactionDeckItems.value ? reactionsRef.value : emojisRef.value;
}
});
}
public async init() {
const emojisComputed = this.createDeckItemCompute();
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src,
asReactionPicker: false,
pinnedEmojis: emojisComputed,
asReactionPicker: true,
manualShowing: this.manualShowing,
choseAndClose: false,
}, {
@ -44,14 +66,18 @@ class EmojiPicker {
public show(
src: HTMLElement,
onChosen: EmojiPicker['onChosen'],
onClosed: EmojiPicker['onClosed'],
onChosen?: EmojiPicker['onChosen'],
onClosed?: EmojiPicker['onClosed'],
itemPresetType: DeckItemPresetType = 'auto',
) {
this.src.value = src;
this.itemPresetType.value = itemPresetType;
this.manualShowing.value = true;
this.onChosen = onChosen;
this.onClosed = onClosed;
}
}
export type DeckItemPresetType = 'reactions' | 'emojis' | 'auto';
export const emojiPicker = new EmojiPicker();

View File

@ -5,6 +5,7 @@
import { defineAsyncComponent, Ref, ref } from 'vue';
import { popup } from '@/os.js';
import { defaultStore } from '@/store.js';
class ReactionPicker {
private src: Ref<HTMLElement | null> = ref(null);
@ -17,8 +18,10 @@ class ReactionPicker {
}
public async init() {
const reactionsRef = defaultStore.reactiveState.reactions;
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
src: this.src,
pinnedEmojis: reactionsRef,
asReactionPicker: true,
manualShowing: this.manualShowing,
}, {
@ -35,7 +38,7 @@ class ReactionPicker {
});
}
public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
this.src.value = src;
this.manualShowing.value = true;
this.onChosen = onChosen;

View File

@ -119,6 +119,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
},
emojiDeckItems: {
where: 'account',
default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
},
reactionAcceptance: {
where: 'account',
default: 'nonSensitiveOnly' as 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null,
@ -339,6 +343,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
useReactionDeckItems: {
where: 'device',
default: true,
},
reactionsDisplaySize: {
where: 'device',
default: 'medium' as 'small' | 'medium' | 'large',