feat: emoji autocomplete

This commit is contained in:
Shusui MOYATANI
2023-07-02 11:41:31 +09:00
parent 2f372b115f
commit 916fc8707f
6 changed files with 149 additions and 16 deletions

81
package-lock.json generated
View File

@@ -16,6 +16,8 @@
"@tanstack/query-sync-storage-persister": "^4.29.19", "@tanstack/query-sync-storage-persister": "^4.29.19",
"@tanstack/solid-query": "^4.29.19", "@tanstack/solid-query": "^4.29.19",
"@tanstack/solid-virtual": "^3.0.0-beta.6", "@tanstack/solid-virtual": "^3.0.0-beta.6",
"@textcomplete/core": "^0.1.12",
"@textcomplete/textarea": "^0.1.12",
"@thisbeyond/solid-dnd": "^0.7.4", "@thisbeyond/solid-dnd": "^0.7.4",
"@types/lodash": "^4.14.195", "@types/lodash": "^4.14.195",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
@@ -1308,6 +1310,32 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@textcomplete/core": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@textcomplete/core/-/core-0.1.12.tgz",
"integrity": "sha512-37Q8Wic3IGpZHtknlJ/ODKMyvaBhVMM56Vl7aoBfno2Qq099fFqoCL0VzKhTn8qvTf1Z/8ymlHwPCBzPvW7KSQ==",
"dependencies": {
"eventemitter3": "^4.0.4"
}
},
"node_modules/@textcomplete/textarea": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@textcomplete/textarea/-/textarea-0.1.12.tgz",
"integrity": "sha512-E05H4wXr1Q50CrCFBAHewyZqvQEX681V5zleDw/31tr8vl5PDFl6TyFmS1W0jQjlrQfxa5uVvgHCx+gpfICBDQ==",
"dependencies": {
"@textcomplete/utils": "^0.1.11",
"textarea-caret": "^3.1.0",
"undate": "^0.3.0"
},
"peerDependencies": {
"@textcomplete/core": "^0.1.9"
}
},
"node_modules/@textcomplete/utils": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@textcomplete/utils/-/utils-0.1.12.tgz",
"integrity": "sha512-llHhD1FAVwFaaHzs7PU0BZYTpNLDzTccDWbw+5cj0TiB2NOXZGjPm6l7PJrJwN/yUuPDxOHip/3I+kF6OBkBAg=="
},
"node_modules/@thisbeyond/solid-dnd": { "node_modules/@thisbeyond/solid-dnd": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz", "resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz",
@@ -3476,6 +3504,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/execa": { "node_modules/execa": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz",
@@ -6734,6 +6767,11 @@
"integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
"dev": true "dev": true
}, },
"node_modules/textarea-caret": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz",
"integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q=="
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -6962,6 +7000,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undate": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/undate/-/undate-0.3.0.tgz",
"integrity": "sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q=="
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
@@ -8228,6 +8271,29 @@
"@reach/observe-rect": "^1.1.0" "@reach/observe-rect": "^1.1.0"
} }
}, },
"@textcomplete/core": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@textcomplete/core/-/core-0.1.12.tgz",
"integrity": "sha512-37Q8Wic3IGpZHtknlJ/ODKMyvaBhVMM56Vl7aoBfno2Qq099fFqoCL0VzKhTn8qvTf1Z/8ymlHwPCBzPvW7KSQ==",
"requires": {
"eventemitter3": "^4.0.4"
}
},
"@textcomplete/textarea": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@textcomplete/textarea/-/textarea-0.1.12.tgz",
"integrity": "sha512-E05H4wXr1Q50CrCFBAHewyZqvQEX681V5zleDw/31tr8vl5PDFl6TyFmS1W0jQjlrQfxa5uVvgHCx+gpfICBDQ==",
"requires": {
"@textcomplete/utils": "^0.1.11",
"textarea-caret": "^3.1.0",
"undate": "^0.3.0"
}
},
"@textcomplete/utils": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@textcomplete/utils/-/utils-0.1.12.tgz",
"integrity": "sha512-llHhD1FAVwFaaHzs7PU0BZYTpNLDzTccDWbw+5cj0TiB2NOXZGjPm6l7PJrJwN/yUuPDxOHip/3I+kF6OBkBAg=="
},
"@thisbeyond/solid-dnd": { "@thisbeyond/solid-dnd": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz", "resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz",
@@ -9799,6 +9865,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true "dev": true
}, },
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"execa": { "execa": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz",
@@ -12138,6 +12209,11 @@
"integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
"dev": true "dev": true
}, },
"textarea-caret": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz",
"integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q=="
},
"thenify": { "thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -12313,6 +12389,11 @@
"which-boxed-primitive": "^1.0.2" "which-boxed-primitive": "^1.0.2"
} }
}, },
"undate": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/undate/-/undate-0.3.0.tgz",
"integrity": "sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q=="
},
"update-browserslist-db": { "update-browserslist-db": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",

View File

@@ -53,6 +53,8 @@
"@tanstack/query-sync-storage-persister": "^4.29.19", "@tanstack/query-sync-storage-persister": "^4.29.19",
"@tanstack/solid-query": "^4.29.19", "@tanstack/solid-query": "^4.29.19",
"@tanstack/solid-virtual": "^3.0.0-beta.6", "@tanstack/solid-virtual": "^3.0.0-beta.6",
"@textcomplete/core": "^0.1.12",
"@textcomplete/textarea": "^0.1.12",
"@thisbeyond/solid-dnd": "^0.7.4", "@thisbeyond/solid-dnd": "^0.7.4",
"@types/lodash": "^4.14.195", "@types/lodash": "^4.14.195",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",

View File

@@ -17,20 +17,6 @@ const EmojiPicker: Component<EmojiPickerProps> = (props) => {
const { config } = useConfig(); const { config } = useConfig();
const [pickerElement, setPickerElement] = createSignal<HTMLElement | undefined>(undefined); const [pickerElement, setPickerElement] = createSignal<HTMLElement | undefined>(undefined);
/*
const buildCustom = () => {
const emojis = Object.values(config().customEmojis).map(({ shortcode, url }) => ({
id: shortcode,
name: shortcode,
keywords: [shortcode],
skins: [{ src: url }],
}));
console.log(emojis);
return [{ id: 'custom_rabbit', name: 'カスタム絵文字', emojis }];
};
*/
const handleOpen = () => { const handleOpen = () => {
const picker = new Picker({ const picker = new Picker({
data: async () => { data: async () => {
@@ -41,7 +27,6 @@ const EmojiPicker: Component<EmojiPickerProps> = (props) => {
const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json'); const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json');
return response.json(); return response.json();
}, },
// custom: props.customEmojis ? buildCustom() : [],
autoFocus: false, autoFocus: false,
locale: 'ja', locale: 'ja',
theme: 'light', theme: 'light',

View File

@@ -11,6 +11,7 @@ import { Event as NostrEvent } from 'nostr-tools';
import EmojiPicker from '@/components/EmojiPicker'; import EmojiPicker from '@/components/EmojiPicker';
import UserNameDisplay from '@/components/UserDisplayName'; import UserNameDisplay from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useEmojiComplete from '@/hooks/useEmojiComplete';
import usePersistStatus from '@/hooks/usePersistStatus'; import usePersistStatus from '@/hooks/usePersistStatus';
import { textNote } from '@/nostr/event'; import { textNote } from '@/nostr/event';
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote'; import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
@@ -88,6 +89,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
let textAreaRef: HTMLTextAreaElement | undefined; let textAreaRef: HTMLTextAreaElement | undefined;
let fileInputRef: HTMLInputElement | undefined; let fileInputRef: HTMLInputElement | undefined;
const { elementRef: emojiTextAreaRef } = useEmojiComplete();
const [text, setText] = createSignal<string>(''); const [text, setText] = createSignal<string>('');
const [contentWarning, setContentWarning] = createSignal(false); const [contentWarning, setContentWarning] = createSignal(false);
const [contentWarningReason, setContentWarningReason] = createSignal(''); const [contentWarningReason, setContentWarningReason] = createSignal('');
@@ -364,6 +366,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
ref={(el) => { ref={(el) => {
textAreaRef = el; textAreaRef = el;
props.textAreaRef?.(el); props.textAreaRef?.(el);
emojiTextAreaRef(el);
}} }}
name="text" name="text"
class="min-h-[40px] rounded-md border-none focus:ring-rose-300" class="min-h-[40px] rounded-md border-none focus:ring-rose-300"

View File

@@ -1,7 +1,7 @@
import { type Accessor, type Setter } from 'solid-js'; import { type Accessor, type Setter } from 'solid-js';
import { sortBy } from 'lodash';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import uniqBy from 'lodash/uniqBy';
import { Kind, type Event as NostrEvent } from 'nostr-tools'; import { Kind, type Event as NostrEvent } from 'nostr-tools';
import { import {
@@ -54,6 +54,7 @@ type UseConfig = {
saveEmojis: (emojis: CustomEmojiConfig[]) => void; saveEmojis: (emojis: CustomEmojiConfig[]) => void;
removeEmoji: (shortcode: string) => void; removeEmoji: (shortcode: string) => void;
getEmoji: (shortcode: string) => CustomEmojiConfig | undefined; getEmoji: (shortcode: string) => CustomEmojiConfig | undefined;
searchEmojis: (term: string) => CustomEmojiConfig[];
// mute // mute
addMutedPubkey: (pubkey: string) => void; addMutedPubkey: (pubkey: string) => void;
removeMutedPubkey: (pubkey: string) => void; removeMutedPubkey: (pubkey: string) => void;
@@ -171,6 +172,12 @@ const useConfig = (): UseConfig => {
const getEmoji = (shortcode: string): CustomEmojiConfig | undefined => const getEmoji = (shortcode: string): CustomEmojiConfig | undefined =>
config.customEmojis[shortcode]; config.customEmojis[shortcode];
const searchEmojis = (term: string): CustomEmojiConfig[] =>
sortBy(
Object.values(config.customEmojis).filter(({ shortcode }) => shortcode.includes(term)),
[(e) => e.shortcode.length],
);
const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey); const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey);
const hasMutedKeyword = (event: NostrEvent) => { const hasMutedKeyword = (event: NostrEvent) => {
@@ -223,6 +230,7 @@ const useConfig = (): UseConfig => {
saveEmojis, saveEmojis,
removeEmoji, removeEmoji,
getEmoji, getEmoji,
searchEmojis,
// mute // mute
addMutedPubkey, addMutedPubkey,
removeMutedPubkey, removeMutedPubkey,

View File

@@ -0,0 +1,54 @@
import { createEffect, createSignal, onCleanup } from 'solid-js';
import { Textcomplete } from '@textcomplete/core';
import { TextareaEditor } from '@textcomplete/textarea';
import useConfig, { CustomEmojiConfig } from '@/core/useConfig';
const useEmojiComplete = () => {
const { searchEmojis } = useConfig();
const [elementRef, setElementRef] = createSignal<HTMLTextAreaElement | undefined>();
createEffect(() => {
const el = elementRef();
if (el == null) return;
const editor = new TextareaEditor(el);
const textcomplete = new Textcomplete(
editor,
[
{
id: 'customEmoji',
match: /\B:(\w+)$/,
search: (term, callback) => {
callback(searchEmojis(term));
},
template: (config: CustomEmojiConfig) => {
const e = (
<div class="flex gap-1 pb-1">
<img class="h-6 max-w-[3rem]" src={config.url} alt={config.shortcode} />
<div>{config.shortcode}</div>
</div>
) as HTMLElement;
return e.outerHTML;
},
replace: (result: CustomEmojiConfig) => `:${result.shortcode}: `,
},
],
{
dropdown: {
className: 'px-2 pt-2 pb-1 bg-white shadow rounded',
},
},
);
onCleanup(() => {
textcomplete.destroy();
});
});
return { elementRef: setElementRef };
};
export default useEmojiComplete;