From 916fc8707fe12d9ee759a9b09c8671db0581946d Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Sun, 2 Jul 2023 11:41:31 +0900 Subject: [PATCH] feat: emoji autocomplete --- package-lock.json | 81 +++++++++++++++++++++++++++++++++ package.json | 2 + src/components/EmojiPicker.tsx | 15 ------ src/components/NotePostForm.tsx | 3 ++ src/core/useConfig.ts | 10 +++- src/hooks/useEmojiComplete.tsx | 54 ++++++++++++++++++++++ 6 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useEmojiComplete.tsx diff --git a/package-lock.json b/package-lock.json index fe77204..7806219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@tanstack/query-sync-storage-persister": "^4.29.19", "@tanstack/solid-query": "^4.29.19", "@tanstack/solid-virtual": "^3.0.0-beta.6", + "@textcomplete/core": "^0.1.12", + "@textcomplete/textarea": "^0.1.12", "@thisbeyond/solid-dnd": "^0.7.4", "@types/lodash": "^4.14.195", "emoji-mart": "^5.5.2", @@ -1308,6 +1310,32 @@ "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": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz", @@ -3476,6 +3504,11 @@ "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": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", @@ -6734,6 +6767,11 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6962,6 +7000,11 @@ "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": { "version": "1.0.10", "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" } }, + "@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": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@thisbeyond/solid-dnd/-/solid-dnd-0.7.4.tgz", @@ -9799,6 +9865,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "execa": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", @@ -12138,6 +12209,11 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -12313,6 +12389,11 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", diff --git a/package.json b/package.json index 2a122ed..95c7ef8 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "@tanstack/query-sync-storage-persister": "^4.29.19", "@tanstack/solid-query": "^4.29.19", "@tanstack/solid-virtual": "^3.0.0-beta.6", + "@textcomplete/core": "^0.1.12", + "@textcomplete/textarea": "^0.1.12", "@thisbeyond/solid-dnd": "^0.7.4", "@types/lodash": "^4.14.195", "emoji-mart": "^5.5.2", diff --git a/src/components/EmojiPicker.tsx b/src/components/EmojiPicker.tsx index 49168ad..3ad2465 100644 --- a/src/components/EmojiPicker.tsx +++ b/src/components/EmojiPicker.tsx @@ -17,20 +17,6 @@ const EmojiPicker: Component = (props) => { const { config } = useConfig(); const [pickerElement, setPickerElement] = createSignal(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 picker = new Picker({ data: async () => { @@ -41,7 +27,6 @@ const EmojiPicker: Component = (props) => { const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json'); return response.json(); }, - // custom: props.customEmojis ? buildCustom() : [], autoFocus: false, locale: 'ja', theme: 'light', diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 7ea8e1c..34b2971 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -11,6 +11,7 @@ import { Event as NostrEvent } from 'nostr-tools'; import EmojiPicker from '@/components/EmojiPicker'; import UserNameDisplay from '@/components/UserDisplayName'; import useConfig from '@/core/useConfig'; +import useEmojiComplete from '@/hooks/useEmojiComplete'; import usePersistStatus from '@/hooks/usePersistStatus'; import { textNote } from '@/nostr/event'; import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote'; @@ -88,6 +89,7 @@ const NotePostForm: Component = (props) => { let textAreaRef: HTMLTextAreaElement | undefined; let fileInputRef: HTMLInputElement | undefined; + const { elementRef: emojiTextAreaRef } = useEmojiComplete(); const [text, setText] = createSignal(''); const [contentWarning, setContentWarning] = createSignal(false); const [contentWarningReason, setContentWarningReason] = createSignal(''); @@ -364,6 +366,7 @@ const NotePostForm: Component = (props) => { ref={(el) => { textAreaRef = el; props.textAreaRef?.(el); + emojiTextAreaRef(el); }} name="text" class="min-h-[40px] rounded-md border-none focus:ring-rose-300" diff --git a/src/core/useConfig.ts b/src/core/useConfig.ts index 88ff560..2c11d0e 100644 --- a/src/core/useConfig.ts +++ b/src/core/useConfig.ts @@ -1,7 +1,7 @@ import { type Accessor, type Setter } from 'solid-js'; +import { sortBy } from 'lodash'; import uniq from 'lodash/uniq'; -import uniqBy from 'lodash/uniqBy'; import { Kind, type Event as NostrEvent } from 'nostr-tools'; import { @@ -54,6 +54,7 @@ type UseConfig = { saveEmojis: (emojis: CustomEmojiConfig[]) => void; removeEmoji: (shortcode: string) => void; getEmoji: (shortcode: string) => CustomEmojiConfig | undefined; + searchEmojis: (term: string) => CustomEmojiConfig[]; // mute addMutedPubkey: (pubkey: string) => void; removeMutedPubkey: (pubkey: string) => void; @@ -171,6 +172,12 @@ const useConfig = (): UseConfig => { const getEmoji = (shortcode: string): CustomEmojiConfig | undefined => 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 hasMutedKeyword = (event: NostrEvent) => { @@ -223,6 +230,7 @@ const useConfig = (): UseConfig => { saveEmojis, removeEmoji, getEmoji, + searchEmojis, // mute addMutedPubkey, removeMutedPubkey, diff --git a/src/hooks/useEmojiComplete.tsx b/src/hooks/useEmojiComplete.tsx new file mode 100644 index 0000000..6837d9b --- /dev/null +++ b/src/hooks/useEmojiComplete.tsx @@ -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(); + + 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 = ( +
+ {config.shortcode} +
{config.shortcode}
+
+ ) 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;